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 aa827598bd0..53746d1aef7 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 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
+ */
+ protected $tagNamespace;
+
/**
* @var bool
*/
@@ -301,7 +310,7 @@ protected function bootTags()
->unique();
foreach ($tags as $class) {
- $class::register();
+ $class::register($this->tagNamespace);
}
return $this;
diff --git a/src/Tags/FluentTag.php b/src/Tags/FluentTag.php
index 660bec0eff9..916ab86246a 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,15 +110,10 @@ public function fetch()
return $this->fetched;
}
- $name = $this->name;
+ [$name, $methodPart] = TagIdentifierAnalyzer::splitNameAndMethodPart($this->name);
- if ($pos = strpos($name, ':')) {
- $originalMethod = substr($name, $pos + 1);
- $method = Str::camel($originalMethod);
- $name = substr($name, 0, $pos);
- } 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 86df6d411dd..fa69c4a37bc 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]);
+ $identifier->name = trim($name);
+
+ if ($methodPart === null) {
$identifier->methodPart = null;
$identifier->compound = $identifier->name;
- } elseif (count($parts) > 1) {
- $name = array_shift($parts);
- $methodPart = implode(':', $parts);
-
- $identifier->name = trim($name);
+ } else {
$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,23 @@ 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)
+ {
+ // 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 [substr($input, 0, $pos), substr($input, $pos + 1)];
+ }
}
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/Addons/NamespacedTagsTest.php b/tests/Addons/NamespacedTagsTest.php
new file mode 100644
index 00000000000..84017a167fd
--- /dev/null
+++ b/tests/Addons/NamespacedTagsTest.php
@@ -0,0 +1,76 @@
+makeProvider('acme')->callBootTags();
+
+ $tags = $this->app['statamic.tags'];
+
+ $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'));
+ }
+
+ #[Test]
+ public function it_does_not_register_namespaced_tags_without_a_tag_namespace()
+ {
+ $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];
+
+ public function __construct($app, $tagNamespace)
+ {
+ parent::__construct($app);
+
+ $this->tagNamespace = $tagNamespace;
+ }
+
+ protected function autoloadFilesFromFolder($folder, $requiredClass = null): array
+ {
+ return [];
+ }
+
+ public function callBootTags()
+ {
+ return $this->bootTags();
+ }
+ };
+ }
+}
+
+class NamespacedTestTag extends Tags
+{
+ protected static $handle = 'namespaced_test';
+
+ protected static $aliases = ['namespaced_test_alias'];
+
+ public function index(): string
+ {
+ return 'hello';
+ }
+}
diff --git a/tests/Antlers/Runtime/NamespacedTagsTest.php b/tests/Antlers/Runtime/NamespacedTagsTest.php
new file mode 100644
index 00000000000..896eeeeee05
--- /dev/null
+++ b/tests/Antlers/Runtime/NamespacedTagsTest.php
@@ -0,0 +1,108 @@
+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_is_not_registered_for_namespaced_tags()
+ {
+ $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()
+ {
+ $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'));
+ }
+
+ 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');
+ }
+}
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');
+ }
+}