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'); + } +}