From e7db091ee73a8607a892ef5c50c733a669d00ed9 Mon Sep 17 00:00:00 2001 From: Marco Rieser Date: Wed, 10 Jun 2026 11:47:13 +0200 Subject: [PATCH 1/4] support namespaced addon tags via `$tagNamespace` --- src/Providers/AddonServiceProvider.php | 30 +++++ src/Tags/FluentTag.php | 8 +- .../Analyzers/TagIdentifierAnalyzer.php | 41 ++++-- tests/Addons/NamespacedTagsTest.php | 83 ++++++++++++ tests/Antlers/Runtime/NamespacedTagsTest.php | 125 ++++++++++++++++++ 5 files changed, 273 insertions(+), 14 deletions(-) create mode 100644 tests/Addons/NamespacedTagsTest.php create mode 100644 tests/Antlers/Runtime/NamespacedTagsTest.php diff --git a/src/Providers/AddonServiceProvider.php b/src/Providers/AddonServiceProvider.php index aa827598bd0..31ac29cfbaa 100644 --- a/src/Providers/AddonServiceProvider.php +++ b/src/Providers/AddonServiceProvider.php @@ -170,6 +170,15 @@ abstract class AddonServiceProvider extends ServiceProvider */ protected $viewNamespace; + /** + * When set, the addon's tags (and their aliases) are additionally + * registered under this namespace, e.g. `{{ my-namespace::my_tag }}`. + * Must be a simple slug without colons. + * + * @var string|null + */ + protected $tagNamespace; + /** * @var bool */ @@ -304,9 +313,30 @@ protected function bootTags() $class::register(); } + if ($this->tagNamespace) { + $this->registerNamespacedTags($tags); + } + return $this; } + private function registerNamespacedTags($tags) + { + $extensions = app('statamic.extensions'); + + $extensions[Tags::class] = with($extensions[Tags::class] ?? collect(), function ($bindings) use ($tags) { + foreach ($tags as $class) { + $bindings[$this->tagNamespace.'::'.$class::handle()] = $class; + + foreach ($class::aliases() as $alias) { + $bindings[$this->tagNamespace.'::'.$alias] = $class; + } + } + + return $bindings; + }); + } + protected function bootScopes() { $scopes = collect($this->scopes) diff --git a/src/Tags/FluentTag.php b/src/Tags/FluentTag.php index 660bec0eff9..2ca26ecd4fc 100644 --- a/src/Tags/FluentTag.php +++ b/src/Tags/FluentTag.php @@ -4,6 +4,7 @@ use ArrayIterator; use Statamic\Support\Str; +use Statamic\View\Antlers\Language\Analyzers\TagIdentifierAnalyzer; use Traversable; class FluentTag implements \ArrayAccess, \IteratorAggregate @@ -109,12 +110,11 @@ public function fetch() return $this->fetched; } - $name = $this->name; + [$name, $methodPart] = TagIdentifierAnalyzer::splitNameAndMethodPart($this->name); - if ($pos = strpos($name, ':')) { - $originalMethod = substr($name, $pos + 1); + if ($methodPart !== null && $methodPart !== '') { + $originalMethod = $methodPart; $method = Str::camel($originalMethod); - $name = substr($name, 0, $pos); } else { $method = $originalMethod = 'index'; } diff --git a/src/View/Antlers/Language/Analyzers/TagIdentifierAnalyzer.php b/src/View/Antlers/Language/Analyzers/TagIdentifierAnalyzer.php index 86df6d411dd..256ec7e60ea 100644 --- a/src/View/Antlers/Language/Analyzers/TagIdentifierAnalyzer.php +++ b/src/View/Antlers/Language/Analyzers/TagIdentifierAnalyzer.php @@ -26,22 +26,16 @@ public static function getIdentifier($input) $identifier = new TagIdentifier(); $identifier->content = trim($input); - $parts = explode(':', $input); + [$name, $methodPart] = self::splitNameAndMethodPart($input); - if (count($parts) == 1) { - $identifier->name = trim($parts[0]); + if ($methodPart === null) { + $identifier->name = trim($name); $identifier->methodPart = null; $identifier->compound = $identifier->name; - } elseif (count($parts) > 1) { - $name = array_shift($parts); - $methodPart = implode(':', $parts); - + } else { $identifier->name = trim($name); $identifier->methodPart = trim($methodPart); $identifier->compound = $identifier->name.':'.$identifier->methodPart; - } else { - $identifier->name = trim($input); - $identifier->methodPart = ''; } if (Str::startsWith($identifier->name, '/')) { @@ -51,4 +45,31 @@ public static function getIdentifier($input) return $identifier; } + + /** + * Splits the input into the tag name and method part at the first + * single colon. Double colons act as a namespace separator and + * remain part of the name (e.g. `ns::tag:method`). + * + * @param string $input The content to split. + * @return array + */ + public static function splitNameAndMethodPart($input) + { + $len = strlen($input); + + for ($i = 0; $i < $len; $i++) { + if ($input[$i] === ':') { + if ($i + 1 < $len && $input[$i + 1] === ':') { + $i++; + + continue; + } + + return [substr($input, 0, $i), substr($input, $i + 1)]; + } + } + + return [$input, null]; + } } diff --git a/tests/Addons/NamespacedTagsTest.php b/tests/Addons/NamespacedTagsTest.php new file mode 100644 index 00000000000..56066d56032 --- /dev/null +++ b/tests/Addons/NamespacedTagsTest.php @@ -0,0 +1,83 @@ +app) extends AddonServiceProvider + { + protected $tags = [NamespacedTestTag::class]; + + protected $tagNamespace = 'acme'; + + protected function autoloadFilesFromFolder($folder, $requiredClass = null) + { + return []; + } + + public function callBootTags() + { + return $this->bootTags(); + } + }; + + $provider->callBootTags(); + + $tags = $this->app['statamic.tags']; + + $this->assertSame(NamespacedTestTag::class, $tags->get('namespaced_test')); + $this->assertSame(NamespacedTestTag::class, $tags->get('namespaced_test_alias')); + $this->assertSame(NamespacedTestTag::class, $tags->get('acme::namespaced_test')); + $this->assertSame(NamespacedTestTag::class, $tags->get('acme::namespaced_test_alias')); + } + + #[Test] + public function it_does_not_register_namespaced_tags_without_a_tag_namespace() + { + $provider = new class($this->app) extends AddonServiceProvider + { + protected $tags = [NamespacedTestTag::class]; + + protected function autoloadFilesFromFolder($folder, $requiredClass = null) + { + return []; + } + + public function callBootTags() + { + return $this->bootTags(); + } + }; + + $provider->callBootTags(); + + $tags = $this->app['statamic.tags']; + + $this->assertSame(NamespacedTestTag::class, $tags->get('namespaced_test')); + $this->assertSame(NamespacedTestTag::class, $tags->get('namespaced_test_alias')); + $this->assertNull($tags->get('acme::namespaced_test')); + $this->assertNull($tags->get('acme::namespaced_test_alias')); + } +} + +class NamespacedTestTag extends Tags +{ + protected static $handle = 'namespaced_test'; + + protected static $aliases = ['namespaced_test_alias']; + + public function index() + { + return 'hello'; + } +} diff --git a/tests/Antlers/Runtime/NamespacedTagsTest.php b/tests/Antlers/Runtime/NamespacedTagsTest.php new file mode 100644 index 00000000000..b19a2763c56 --- /dev/null +++ b/tests/Antlers/Runtime/NamespacedTagsTest.php @@ -0,0 +1,125 @@ +registerNamespacedTag('acme', new class extends Tags + { + public static $handle = 'ns_greet'; + + protected static $aliases = ['ns_hi']; + + public function index() + { + return 'greetings'; + } + + public function hello() + { + return 'hi'; + } + + public function details() + { + return $this->tag.'|'.$this->method; + } + + public function wildcard($method) + { + return 'wildcard: '.$method; + } + }); + + $this->registerNamespacedTag('acme', new class extends Tags + { + public static $handle = 'ns_items'; + + public function index() + { + return [['value' => 'a'], ['value' => 'b']]; + } + }); + } + + private function registerNamespacedTag(string $namespace, $tag): void + { + $tag::register(); + + $extensions = app('statamic.extensions'); + + $extensions[Tags::class] = with($extensions[Tags::class], function ($bindings) use ($namespace, $tag) { + $bindings[$namespace.'::'.$tag::handle()] = get_class($tag); + + foreach ($tag::aliases() as $alias) { + $bindings[$namespace.'::'.$alias] = get_class($tag); + } + + return $bindings; + }); + } + + public function test_namespaced_tag_with_method_can_be_rendered() + { + $this->assertSame('hi', $this->renderString('{{ acme::ns_greet:hello }}', [], true)); + } + + public function test_namespaced_tag_without_method_calls_index() + { + $this->assertSame('greetings', $this->renderString('{{ acme::ns_greet }}', [], true)); + } + + public function test_namespaced_tag_can_be_paired() + { + $this->assertSame('ab', $this->renderString('{{ acme::ns_items }}{{ value }}{{ /acme::ns_items }}', [], true)); + } + + public function test_namespaced_tag_can_be_self_closing() + { + $this->assertSame('greetings', $this->renderString('{{ acme::ns_greet /}}', [], true)); + } + + public function test_namespaced_alias_can_be_rendered() + { + $this->assertSame('hi', $this->renderString('{{ acme::ns_hi:hello }}', [], true)); + } + + public function test_bare_handle_still_works_alongside_namespace() + { + $this->assertSame('hi', $this->renderString('{{ ns_greet:hello }}', [], true)); + $this->assertSame('hi', $this->renderString('{{ ns_hi:hello }}', [], true)); + } + + public function test_double_colons_in_method_part_route_to_wildcard() + { + $this->assertSame('wildcard: foo::bar', $this->renderString('{{ acme::ns_greet:foo::bar }}', [], true)); + } + + public function test_namespaced_tag_can_be_used_in_conditions() + { + $this->assertSame('yes', $this->renderString('{{ if {acme::ns_greet:hello} == "hi" }}yes{{ /if }}', [], true)); + } + + public function test_unregistered_namespace_falls_back_to_variable() + { + $this->assertSame('', $this->renderString('{{ unknown::ns_greet }}', [], true)); + } + + public function test_namespaced_tag_receives_namespaced_tag_and_method_properties() + { + $this->assertSame('acme::ns_greet:details|details', $this->renderString('{{ acme::ns_greet:details }}', [], true)); + } + + public function test_namespaced_tag_can_be_resolved_fluently() + { + $this->assertSame('hi', (string) Statamic::tag('acme::ns_greet:hello')); + } +} From 6a6957572a03ece9fce1a7c2dc3901afa51410c3 Mon Sep 17 00:00:00 2001 From: Marco Rieser Date: Wed, 10 Jun 2026 11:55:52 +0200 Subject: [PATCH 2/4] Ensure namespaced addon tags are only registered under their namespaces --- src/Providers/AddonServiceProvider.php | 14 ++++++++------ tests/Addons/NamespacedTagsTest.php | 4 ++-- tests/Antlers/Runtime/NamespacedTagsTest.php | 10 ++++------ 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/src/Providers/AddonServiceProvider.php b/src/Providers/AddonServiceProvider.php index 31ac29cfbaa..140f79767dc 100644 --- a/src/Providers/AddonServiceProvider.php +++ b/src/Providers/AddonServiceProvider.php @@ -171,8 +171,8 @@ abstract class AddonServiceProvider extends ServiceProvider protected $viewNamespace; /** - * When set, the addon's tags (and their aliases) are additionally - * registered under this namespace, e.g. `{{ my-namespace::my_tag }}`. + * When set, the addon's tags (and their aliases) are registered under + * this namespace instead of their bare handles, e.g. `{{ my-namespace::my_tag }}`. * Must be a simple slug without colons. * * @var string|null @@ -309,12 +309,12 @@ protected function bootTags() ->merge($this->autoloadFilesFromFolder('Tags', Tags::class)) ->unique(); - foreach ($tags as $class) { - $class::register(); + if ($this->tagNamespace) { + return $this->registerNamespacedTags($tags); } - if ($this->tagNamespace) { - $this->registerNamespacedTags($tags); + foreach ($tags as $class) { + $class::register(); } return $this; @@ -335,6 +335,8 @@ private function registerNamespacedTags($tags) return $bindings; }); + + return $this; } protected function bootScopes() diff --git a/tests/Addons/NamespacedTagsTest.php b/tests/Addons/NamespacedTagsTest.php index 56066d56032..ce78989e437 100644 --- a/tests/Addons/NamespacedTagsTest.php +++ b/tests/Addons/NamespacedTagsTest.php @@ -35,8 +35,8 @@ public function callBootTags() $tags = $this->app['statamic.tags']; - $this->assertSame(NamespacedTestTag::class, $tags->get('namespaced_test')); - $this->assertSame(NamespacedTestTag::class, $tags->get('namespaced_test_alias')); + $this->assertNull($tags->get('namespaced_test')); + $this->assertNull($tags->get('namespaced_test_alias')); $this->assertSame(NamespacedTestTag::class, $tags->get('acme::namespaced_test')); $this->assertSame(NamespacedTestTag::class, $tags->get('acme::namespaced_test_alias')); } diff --git a/tests/Antlers/Runtime/NamespacedTagsTest.php b/tests/Antlers/Runtime/NamespacedTagsTest.php index b19a2763c56..28a7922706a 100644 --- a/tests/Antlers/Runtime/NamespacedTagsTest.php +++ b/tests/Antlers/Runtime/NamespacedTagsTest.php @@ -52,11 +52,9 @@ public function index() private function registerNamespacedTag(string $namespace, $tag): void { - $tag::register(); - $extensions = app('statamic.extensions'); - $extensions[Tags::class] = with($extensions[Tags::class], function ($bindings) use ($namespace, $tag) { + $extensions[Tags::class] = with($extensions[Tags::class] ?? collect(), function ($bindings) use ($namespace, $tag) { $bindings[$namespace.'::'.$tag::handle()] = get_class($tag); foreach ($tag::aliases() as $alias) { @@ -92,10 +90,10 @@ public function test_namespaced_alias_can_be_rendered() $this->assertSame('hi', $this->renderString('{{ acme::ns_hi:hello }}', [], true)); } - public function test_bare_handle_still_works_alongside_namespace() + public function test_bare_handle_is_not_registered_for_namespaced_tags() { - $this->assertSame('hi', $this->renderString('{{ ns_greet:hello }}', [], true)); - $this->assertSame('hi', $this->renderString('{{ ns_hi:hello }}', [], true)); + $this->assertSame('', $this->renderString('{{ ns_greet:hello }}', [], true)); + $this->assertSame('', $this->renderString('{{ ns_hi:hello }}', [], true)); } public function test_double_colons_in_method_part_route_to_wildcard() From d5fedb25d99d0e032c93465b85800df939166366 Mon Sep 17 00:00:00 2001 From: Marco Rieser Date: Wed, 10 Jun 2026 12:08:46 +0200 Subject: [PATCH 3/4] Simplify --- src/Extend/RegistersItself.php | 9 +- src/Providers/AddonServiceProvider.php | 25 +---- src/Tags/FluentTag.php | 8 +- .../Analyzers/TagIdentifierAnalyzer.php | 22 ++--- tests/Addons/NamespacedTagsTest.php | 53 +++++----- tests/Antlers/Runtime/NamespacedTagsTest.php | 99 ++++++++----------- 6 files changed, 80 insertions(+), 136 deletions(-) diff --git a/src/Extend/RegistersItself.php b/src/Extend/RegistersItself.php index f96d355b36f..19a0b2f2a71 100644 --- a/src/Extend/RegistersItself.php +++ b/src/Extend/RegistersItself.php @@ -4,17 +4,18 @@ trait RegistersItself { - public static function register() + public static function register(?string $namespace = null) { $key = self::class; + $prefix = $namespace ? $namespace.'::' : ''; $extensions = app('statamic.extensions'); - $extensions[$key] = with($extensions[$key] ?? collect(), function ($bindings) { - $bindings[static::handle()] = static::class; + $extensions[$key] = with($extensions[$key] ?? collect(), function ($bindings) use ($prefix) { + $bindings[$prefix.static::handle()] = static::class; if (method_exists(static::class, 'aliases')) { foreach (static::aliases() as $alias) { - $bindings[$alias] = static::class; + $bindings[$prefix.$alias] = static::class; } } diff --git a/src/Providers/AddonServiceProvider.php b/src/Providers/AddonServiceProvider.php index 140f79767dc..53746d1aef7 100644 --- a/src/Providers/AddonServiceProvider.php +++ b/src/Providers/AddonServiceProvider.php @@ -309,36 +309,13 @@ protected function bootTags() ->merge($this->autoloadFilesFromFolder('Tags', Tags::class)) ->unique(); - if ($this->tagNamespace) { - return $this->registerNamespacedTags($tags); - } - foreach ($tags as $class) { - $class::register(); + $class::register($this->tagNamespace); } return $this; } - private function registerNamespacedTags($tags) - { - $extensions = app('statamic.extensions'); - - $extensions[Tags::class] = with($extensions[Tags::class] ?? collect(), function ($bindings) use ($tags) { - foreach ($tags as $class) { - $bindings[$this->tagNamespace.'::'.$class::handle()] = $class; - - foreach ($class::aliases() as $alias) { - $bindings[$this->tagNamespace.'::'.$alias] = $class; - } - } - - return $bindings; - }); - - return $this; - } - protected function bootScopes() { $scopes = collect($this->scopes) diff --git a/src/Tags/FluentTag.php b/src/Tags/FluentTag.php index 2ca26ecd4fc..916ab86246a 100644 --- a/src/Tags/FluentTag.php +++ b/src/Tags/FluentTag.php @@ -112,12 +112,8 @@ public function fetch() [$name, $methodPart] = TagIdentifierAnalyzer::splitNameAndMethodPart($this->name); - if ($methodPart !== null && $methodPart !== '') { - $originalMethod = $methodPart; - $method = Str::camel($originalMethod); - } else { - $method = $originalMethod = 'index'; - } + $originalMethod = $methodPart ?: 'index'; + $method = Str::camel($originalMethod); $tagName = $name.':'.$originalMethod; $profileTagName = 'tag_'.$tagName.microtime(); diff --git a/src/View/Antlers/Language/Analyzers/TagIdentifierAnalyzer.php b/src/View/Antlers/Language/Analyzers/TagIdentifierAnalyzer.php index 256ec7e60ea..fa69c4a37bc 100644 --- a/src/View/Antlers/Language/Analyzers/TagIdentifierAnalyzer.php +++ b/src/View/Antlers/Language/Analyzers/TagIdentifierAnalyzer.php @@ -28,12 +28,12 @@ public static function getIdentifier($input) [$name, $methodPart] = self::splitNameAndMethodPart($input); + $identifier->name = trim($name); + if ($methodPart === null) { - $identifier->name = trim($name); $identifier->methodPart = null; $identifier->compound = $identifier->name; } else { - $identifier->name = trim($name); $identifier->methodPart = trim($methodPart); $identifier->compound = $identifier->name.':'.$identifier->methodPart; } @@ -56,20 +56,12 @@ public static function getIdentifier($input) */ public static function splitNameAndMethodPart($input) { - $len = strlen($input); - - for ($i = 0; $i < $len; $i++) { - if ($input[$i] === ':') { - if ($i + 1 < $len && $input[$i + 1] === ':') { - $i++; - - continue; - } - - return [substr($input, 0, $i), substr($input, $i + 1)]; - } + // Mask double colons so the namespace separator + // is not mistaken for the name/method boundary. + if (($pos = strpos(strtr($input, ['::' => '__']), ':')) === false) { + return [$input, null]; } - return [$input, null]; + return [substr($input, 0, $pos), substr($input, $pos + 1)]; } } diff --git a/tests/Addons/NamespacedTagsTest.php b/tests/Addons/NamespacedTagsTest.php index ce78989e437..84017a167fd 100644 --- a/tests/Addons/NamespacedTagsTest.php +++ b/tests/Addons/NamespacedTagsTest.php @@ -14,24 +14,7 @@ class NamespacedTagsTest extends TestCase #[Test] public function it_registers_namespaced_tags_when_a_tag_namespace_is_set() { - $provider = new class($this->app) extends AddonServiceProvider - { - protected $tags = [NamespacedTestTag::class]; - - protected $tagNamespace = 'acme'; - - protected function autoloadFilesFromFolder($folder, $requiredClass = null) - { - return []; - } - - public function callBootTags() - { - return $this->bootTags(); - } - }; - - $provider->callBootTags(); + $this->makeProvider('acme')->callBootTags(); $tags = $this->app['statamic.tags']; @@ -44,11 +27,30 @@ public function callBootTags() #[Test] public function it_does_not_register_namespaced_tags_without_a_tag_namespace() { - $provider = new class($this->app) extends AddonServiceProvider + $this->makeProvider(null)->callBootTags(); + + $tags = $this->app['statamic.tags']; + + $this->assertSame(NamespacedTestTag::class, $tags->get('namespaced_test')); + $this->assertSame(NamespacedTestTag::class, $tags->get('namespaced_test_alias')); + $this->assertNull($tags->get('acme::namespaced_test')); + $this->assertNull($tags->get('acme::namespaced_test_alias')); + } + + private function makeProvider(?string $tagNamespace): AddonServiceProvider + { + return new class($this->app, $tagNamespace) extends AddonServiceProvider { protected $tags = [NamespacedTestTag::class]; - protected function autoloadFilesFromFolder($folder, $requiredClass = null) + public function __construct($app, $tagNamespace) + { + parent::__construct($app); + + $this->tagNamespace = $tagNamespace; + } + + protected function autoloadFilesFromFolder($folder, $requiredClass = null): array { return []; } @@ -58,15 +60,6 @@ public function callBootTags() return $this->bootTags(); } }; - - $provider->callBootTags(); - - $tags = $this->app['statamic.tags']; - - $this->assertSame(NamespacedTestTag::class, $tags->get('namespaced_test')); - $this->assertSame(NamespacedTestTag::class, $tags->get('namespaced_test_alias')); - $this->assertNull($tags->get('acme::namespaced_test')); - $this->assertNull($tags->get('acme::namespaced_test_alias')); } } @@ -76,7 +69,7 @@ class NamespacedTestTag extends Tags protected static $aliases = ['namespaced_test_alias']; - public function index() + public function index(): string { return 'hello'; } diff --git a/tests/Antlers/Runtime/NamespacedTagsTest.php b/tests/Antlers/Runtime/NamespacedTagsTest.php index 28a7922706a..896eeeeee05 100644 --- a/tests/Antlers/Runtime/NamespacedTagsTest.php +++ b/tests/Antlers/Runtime/NamespacedTagsTest.php @@ -8,63 +8,6 @@ class NamespacedTagsTest extends ParserTestCase { - protected function setUp(): void - { - parent::setUp(); - - $this->registerNamespacedTag('acme', new class extends Tags - { - public static $handle = 'ns_greet'; - - protected static $aliases = ['ns_hi']; - - public function index() - { - return 'greetings'; - } - - public function hello() - { - return 'hi'; - } - - public function details() - { - return $this->tag.'|'.$this->method; - } - - public function wildcard($method) - { - return 'wildcard: '.$method; - } - }); - - $this->registerNamespacedTag('acme', new class extends Tags - { - public static $handle = 'ns_items'; - - public function index() - { - return [['value' => 'a'], ['value' => 'b']]; - } - }); - } - - private function registerNamespacedTag(string $namespace, $tag): void - { - $extensions = app('statamic.extensions'); - - $extensions[Tags::class] = with($extensions[Tags::class] ?? collect(), function ($bindings) use ($namespace, $tag) { - $bindings[$namespace.'::'.$tag::handle()] = get_class($tag); - - foreach ($tag::aliases() as $alias) { - $bindings[$namespace.'::'.$alias] = get_class($tag); - } - - return $bindings; - }); - } - public function test_namespaced_tag_with_method_can_be_rendered() { $this->assertSame('hi', $this->renderString('{{ acme::ns_greet:hello }}', [], true)); @@ -120,4 +63,46 @@ public function test_namespaced_tag_can_be_resolved_fluently() { $this->assertSame('hi', (string) Statamic::tag('acme::ns_greet:hello')); } + + protected function setUp(): void + { + parent::setUp(); + + (new class extends Tags + { + public static $handle = 'ns_greet'; + + protected static $aliases = ['ns_hi']; + + public function index() + { + return 'greetings'; + } + + public function hello() + { + return 'hi'; + } + + public function details() + { + return $this->tag.'|'.$this->method; + } + + public function wildcard($method) + { + return 'wildcard: '.$method; + } + })::register('acme'); + + (new class extends Tags + { + public static $handle = 'ns_items'; + + public function index() + { + return [['value' => 'a'], ['value' => 'b']]; + } + })::register('acme'); + } } From a13083fd60013b4a6ad030bf9f3f0ffc0a6930b9 Mon Sep 17 00:00:00 2001 From: Marco Rieser Date: Wed, 10 Jun 2026 12:19:44 +0200 Subject: [PATCH 4/4] Add support for Blade --- src/View/Blade/StatamicTagCompiler.php | 14 ++-- .../AntlersComponents/NamespacedTagsTest.php | 75 +++++++++++++++++++ 2 files changed, 80 insertions(+), 9 deletions(-) create mode 100644 tests/View/Blade/AntlersComponents/NamespacedTagsTest.php diff --git a/src/View/Blade/StatamicTagCompiler.php b/src/View/Blade/StatamicTagCompiler.php index 45d7dfc0ad9..3f9d4380b58 100644 --- a/src/View/Blade/StatamicTagCompiler.php +++ b/src/View/Blade/StatamicTagCompiler.php @@ -3,6 +3,7 @@ namespace Statamic\View\Blade; use Illuminate\Support\Str; +use Statamic\View\Antlers\Language\Analyzers\TagIdentifierAnalyzer; use Statamic\View\Blade\Concerns\CompilesComponents; use Statamic\View\Blade\Concerns\CompilesNavs; use Statamic\View\Blade\Concerns\CompilesNocache; @@ -125,15 +126,10 @@ protected function isPartial(ComponentNode $component): bool protected function extractMethodNames(ComponentNode $component): array { - $name = $component->tagName; - - if ($pos = strpos($name, ':')) { - $originalMethod = substr($name, $pos + 1); - $method = Str::camel($originalMethod); - $name = substr($name, 0, $pos); - } else { - $method = $originalMethod = 'index'; - } + [$name, $methodPart] = TagIdentifierAnalyzer::splitNameAndMethodPart($component->tagName); + + $originalMethod = $methodPart ?: 'index'; + $method = Str::camel($originalMethod); return [$name, $method, $originalMethod]; } diff --git a/tests/View/Blade/AntlersComponents/NamespacedTagsTest.php b/tests/View/Blade/AntlersComponents/NamespacedTagsTest.php new file mode 100644 index 00000000000..704a8f6c30a --- /dev/null +++ b/tests/View/Blade/AntlersComponents/NamespacedTagsTest.php @@ -0,0 +1,75 @@ +assertSame('hi', Blade::render('')); + } + + #[Test] + public function it_renders_namespaced_tags_without_a_method() + { + $this->assertSame('greetings', Blade::render('')); + } + + #[Test] + public function it_renders_paired_namespaced_tags() + { + $template = <<<'BLADE' +{{ $value }} +BLADE; + + $this->assertSame('ab', Blade::render($template)); + } + + #[Test] + public function it_renders_namespaced_tag_aliases() + { + $this->assertSame('hi', Blade::render('')); + } + + protected function setUp(): void + { + parent::setUp(); + + $this->artisan('view:clear'); + + (new class extends Tags + { + public static $handle = 'ns_greet'; + + protected static $aliases = ['ns_hi']; + + public function index() + { + return 'greetings'; + } + + public function hello() + { + return 'hi'; + } + })::register('acme'); + + (new class extends Tags + { + public static $handle = 'ns_items'; + + public function index() + { + return [['value' => 'a'], ['value' => 'b']]; + } + })::register('acme'); + } +}