From 710fd38db3ab40e0fcf6165ea6704b2dd513fe00 Mon Sep 17 00:00:00 2001 From: Daniel Jones Date: Tue, 10 Nov 2026 18:51:29 +0000 Subject: [PATCH 1/2] Build URLs for placeholder routes without creating an Antlers parser --- src/Routing/UrlBuilder.php | 75 ++++++++++++++++++++++++-- tests/Routing/UrlBuilderTest.php | 92 ++++++++++++++++++++++++++++++++ 2 files changed, 164 insertions(+), 3 deletions(-) diff --git a/src/Routing/UrlBuilder.php b/src/Routing/UrlBuilder.php index f2044eef79e..4a111efcbb8 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,61 @@ 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 + { + 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..7972d0b3677 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,96 @@ 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); + } + + 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() { From 45785edffa0f605f87e7d781e079814413e28c7e Mon Sep 17 00:00:00 2001 From: Daniel Jones Date: Wed, 11 Nov 2026 03:20:14 +0000 Subject: [PATCH 2/2] Leave routes that might contain a component tag to Antlers Antlers compiles component tags such as before parsing, so a route with one in it has to keep going through Antlers. --- src/Routing/UrlBuilder.php | 3 ++- tests/Routing/UrlBuilderTest.php | 8 ++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/Routing/UrlBuilder.php b/src/Routing/UrlBuilder.php index 4a111efcbb8..142e42d8c06 100644 --- a/src/Routing/UrlBuilder.php +++ b/src/Routing/UrlBuilder.php @@ -86,7 +86,8 @@ public function build($route) */ private function fillPlaceholders($route): ?string { - if (! is_string($route) || Str::contains($route, '{{')) { + // Antlers also compiles component tags (), so anything that might be one is left to it. + if (! is_string($route) || Str::contains($route, ['{{', '<'])) { return null; } diff --git a/tests/Routing/UrlBuilderTest.php b/tests/Routing/UrlBuilderTest.php index 7972d0b3677..0dc5507fd5b 100644 --- a/tests/Routing/UrlBuilderTest.php +++ b/tests/Routing/UrlBuilderTest.php @@ -161,6 +161,14 @@ public function it_uses_antlers_when_a_route_is_more_than_placeholders($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 [