Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions src/Extend/RegistersItself.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}

Expand Down
11 changes: 10 additions & 1 deletion src/Providers/AddonServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down Expand Up @@ -301,7 +310,7 @@ protected function bootTags()
->unique();

foreach ($tags as $class) {
$class::register();
$class::register($this->tagNamespace);
}

return $this;
Expand Down
12 changes: 4 additions & 8 deletions src/Tags/FluentTag.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

use ArrayIterator;
use Statamic\Support\Str;
use Statamic\View\Antlers\Language\Analyzers\TagIdentifierAnalyzer;
use Traversable;

class FluentTag implements \ArrayAccess, \IteratorAggregate
Expand Down Expand Up @@ -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();
Expand Down
35 changes: 24 additions & 11 deletions src/View/Antlers/Language/Analyzers/TagIdentifierAnalyzer.php
Original file line number Diff line number Diff line change
Expand Up @@ -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, '/')) {
Expand All @@ -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)];
}
}
14 changes: 5 additions & 9 deletions src/View/Blade/StatamicTagCompiler.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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];
}
Expand Down
76 changes: 76 additions & 0 deletions tests/Addons/NamespacedTagsTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
<?php

namespace Tests\Addons;

use PHPUnit\Framework\Attributes\Group;
use PHPUnit\Framework\Attributes\Test;
use Statamic\Providers\AddonServiceProvider;
use Statamic\Tags\Tags;
use Tests\TestCase;

#[Group('addons')]
class NamespacedTagsTest extends TestCase
{
#[Test]
public function it_registers_namespaced_tags_when_a_tag_namespace_is_set()
{
$this->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';
}
}
108 changes: 108 additions & 0 deletions tests/Antlers/Runtime/NamespacedTagsTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
<?php

namespace Tests\Antlers\Runtime;

use Statamic\Statamic;
use Statamic\Tags\Tags;
use Tests\Antlers\ParserTestCase;

class NamespacedTagsTest extends ParserTestCase
{
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_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');
}
}
Loading
Loading