From e37e83662816afae6e077618ef8ae0d28d4a65a1 Mon Sep 17 00:00:00 2001 From: Ricardo Gobbo de Souza Date: Thu, 17 Sep 2026 10:44:00 -0300 Subject: [PATCH] Add support for custom component tag prefixes --- src/Blaze.php | 1 + src/BlazeManager.php | 12 ++++++++- src/BlazeServiceProvider.php | 1 + src/Parser/Nodes/ComponentNode.php | 17 ++++--------- src/Parser/Parser.php | 2 ++ src/Parser/PrefixRegistry.php | 35 ++++++++++++++++++++++++++ src/Parser/Tokenizer.php | 24 ++++-------------- tests/BlazeManagerTest.php | 15 ++++++++++++ tests/Parser/ParserTest.php | 15 +++++++++--- tests/Parser/PrefixRegistryTest.php | 38 +++++++++++++++++++++++++++++ tests/Parser/TokenizerTest.php | 14 +++++++++++ 11 files changed, 139 insertions(+), 35 deletions(-) create mode 100644 src/Parser/PrefixRegistry.php create mode 100644 tests/Parser/PrefixRegistryTest.php diff --git a/src/Blaze.php b/src/Blaze.php index f373d4f5..bf99044d 100644 --- a/src/Blaze.php +++ b/src/Blaze.php @@ -23,6 +23,7 @@ * @method static void debug() * @method static bool isDebugging() * @method static string compileForDebug(string $template) + * @method static void registerPrefix(string $prefix, string $namespace = '', string $slotPrefix = 'x-slot') * * @see \Livewire\Blaze\BlazeManager */ diff --git a/src/BlazeManager.php b/src/BlazeManager.php index 2ed5db99..e7c24017 100644 --- a/src/BlazeManager.php +++ b/src/BlazeManager.php @@ -16,6 +16,7 @@ use Livewire\Blaze\Memoizer\Memoizer; use Livewire\Blaze\Parser\Nodes\ComponentNode; use Livewire\Blaze\Parser\Parser; +use Livewire\Blaze\Parser\PrefixRegistry; use Livewire\Blaze\Parser\Tokenizer; use Livewire\Blaze\Parser\Walker; use Livewire\Blaze\Support\Directives; @@ -48,9 +49,10 @@ public function __construct( protected BladeCompiler $bladeCompiler, protected BlazeRuntime $runtime, protected BladeService $blade, + protected PrefixRegistry $registry, ) { $this->renderer = new BladeRenderer($bladeCompiler, app('view'), $this->runtime, $this); - $this->parser = new Parser(new Tokenizer($this->blade), new AttributeParser($this->blade)); + $this->parser = new Parser(new Tokenizer($this->blade, $this->registry), new AttributeParser($this->blade)); $this->walker = new Walker; $this->compiler = new Compiler($config, $this->blade, $this); $this->folder = new Folder($config, $this->blade, $this->renderer, $this); @@ -397,6 +399,14 @@ public function optimize(): Config return $this->config; } + /** + * Register a custom component tag prefix (e.g. 'tk:' => Blade namespace 'tk::'). + */ + public function registerPrefix(string $prefix, string $namespace = '', string $slotPrefix = 'x-slot'): void + { + $this->registry->register($prefix, $namespace, $slotPrefix); + } + /** * Recursively check if any descendant component uses @aware. */ diff --git a/src/BlazeServiceProvider.php b/src/BlazeServiceProvider.php index 5f586471..b3f6c5c2 100644 --- a/src/BlazeServiceProvider.php +++ b/src/BlazeServiceProvider.php @@ -26,6 +26,7 @@ public function register(): void $this->app->singleton(Config::class); $this->app->singleton(Debugger::class); $this->app->singleton(Instrumenter::class); + $this->app->singleton(\Livewire\Blaze\Parser\PrefixRegistry::class); $this->app->singleton(BlazeManager::class); $this->app->singleton(\PhpParser\Parser::class, function () { diff --git a/src/Parser/Nodes/ComponentNode.php b/src/Parser/Nodes/ComponentNode.php index bd82485f..5ef78ea9 100644 --- a/src/Parser/Nodes/ComponentNode.php +++ b/src/Parser/Nodes/ComponentNode.php @@ -13,6 +13,7 @@ class ComponentNode extends Node public function __construct( public string $name, public string $prefix, + public string $namespace = '', public string $attributeString = '', public array $children = [], public bool $selfClosing = false, @@ -49,7 +50,7 @@ public function setParentsAttributes(array $parentsAttributes): void /** {@inheritdoc} */ public function render(): string { - $name = $this->stripNamespaceFromName($this->name, $this->prefix); + $name = $this->stripNamespaceFromName($this->name); $output = "<{$this->prefix}{$name}"; @@ -76,18 +77,10 @@ public function render(): string /** * Strip the namespace prefix from a component name for tag rendering. */ - protected function stripNamespaceFromName(string $name, string $prefix): string + protected function stripNamespaceFromName(string $name): string { - $prefixes = [ - 'flux:' => ['namespace' => 'flux::'], - 'x:' => ['namespace' => ''], - 'x-' => ['namespace' => ''], - ]; - if (isset($prefixes[$prefix])) { - $namespace = $prefixes[$prefix]['namespace']; - if (! empty($namespace) && str_starts_with($name, $namespace)) { - return substr($name, strlen($namespace)); - } + if ($this->namespace !== '' && str_starts_with($name, $this->namespace)) { + return substr($name, strlen($this->namespace)); } return $name; diff --git a/src/Parser/Parser.php b/src/Parser/Parser.php index 8f3751c5..1f0dc7d0 100644 --- a/src/Parser/Parser.php +++ b/src/Parser/Parser.php @@ -63,6 +63,7 @@ protected function handleTagOpen(TagOpenToken $token, ParseStack $stack): void $node = new ComponentNode( name: $token->namespace . $token->name, prefix: $token->prefix, + namespace: $token->namespace, attributeString: $attributeString, children: [], selfClosing: false, @@ -82,6 +83,7 @@ protected function handleTagSelfClose(TagSelfCloseToken $token, ParseStack $stac $node = new ComponentNode( name: $token->namespace . $token->name, prefix: $token->prefix, + namespace: $token->namespace, attributeString: $attributeString, children: [], selfClosing: true, diff --git a/src/Parser/PrefixRegistry.php b/src/Parser/PrefixRegistry.php new file mode 100644 index 00000000..4b5631fc --- /dev/null +++ b/src/Parser/PrefixRegistry.php @@ -0,0 +1,35 @@ + ['namespace' => 'flux::', 'slot' => 'x-slot'], + 'x:' => ['namespace' => '', 'slot' => 'x-slot'], + 'x-' => ['namespace' => '', 'slot' => 'x-slot'], + ]; + + /** + * Register (or overwrite) a component tag prefix. + */ + public function register(string $prefix, string $namespace = '', string $slotPrefix = 'x-slot'): void + { + $this->prefixes[$prefix] = [ + 'namespace' => $namespace, + 'slot' => $slotPrefix, + ]; + } + + /** + * Get all registered prefixes, keyed by prefix string. + */ + public function all(): array + { + return $this->prefixes; + } +} diff --git a/src/Parser/Tokenizer.php b/src/Parser/Tokenizer.php index f96efbf4..a21ee61a 100644 --- a/src/Parser/Tokenizer.php +++ b/src/Parser/Tokenizer.php @@ -21,24 +21,10 @@ class Tokenizer { public function __construct( protected BladeService $blade, + protected PrefixRegistry $registry, ) { } - protected array $prefixes = [ - 'flux:' => [ - 'namespace' => 'flux::', - 'slot' => 'x-slot', - ], - 'x:' => [ - 'namespace' => '', - 'slot' => 'x-slot', - ], - 'x-' => [ - 'namespace' => '', - 'slot' => 'x-slot', - ], - ]; - protected string $content = ''; protected int $position = 0; @@ -495,7 +481,7 @@ protected function collectAttributes(): void */ protected function matchSlotOpen(): ?array { - foreach ($this->prefixes as $prefix => $config) { + foreach ($this->registry->all() as $prefix => $config) { $slotPrefix = $config['slot']; if ($this->match('<\s*' . $slotPrefix . ':')) { @@ -515,7 +501,7 @@ protected function matchSlotOpen(): ?array */ protected function matchSlotClose(): ?array { - foreach ($this->prefixes as $prefix => $config) { + foreach ($this->registry->all() as $prefix => $config) { $slotPrefix = $config['slot']; if ($this->match('<\/\s*' . $slotPrefix)) { @@ -531,7 +517,7 @@ protected function matchSlotClose(): ?array */ protected function matchComponentOpen(): ?array { - foreach ($this->prefixes as $prefix => $config) { + foreach ($this->registry->all() as $prefix => $config) { if ($this->match('<\s*' . $prefix)) { return [ 'prefix' => $prefix, @@ -548,7 +534,7 @@ protected function matchComponentOpen(): ?array */ protected function matchComponentClose(): ?array { - foreach ($this->prefixes as $prefix => $config) { + foreach ($this->registry->all() as $prefix => $config) { if ($this->match('<\/\s*' . $prefix)) { return [ 'prefix' => $prefix, diff --git a/tests/BlazeManagerTest.php b/tests/BlazeManagerTest.php index 52a5947d..27157804 100644 --- a/tests/BlazeManagerTest.php +++ b/tests/BlazeManagerTest.php @@ -1,8 +1,10 @@ Artisan::call('view:clear')); @@ -54,3 +56,16 @@ expect($manager->viewContainsExpiredFrontMatter($view))->toBeFalse(); }); + +test('registerPrefix makes a custom tag prefix compilable', function () { + Blade::anonymousComponentNamespace('components', 'tk'); + + Blaze::registerPrefix('tk:', namespace: 'tk::'); + + $path = fixture_path('views/components/card.blade.php'); + $hash = Utils::hash($path); + + $output = Blaze::compileForFolding(''); + + expect($output)->toContain("_{$hash}("); +}); diff --git a/tests/Parser/ParserTest.php b/tests/Parser/ParserTest.php index 4c214ee3..371e73da 100644 --- a/tests/Parser/ParserTest.php +++ b/tests/Parser/ParserTest.php @@ -1,5 +1,6 @@ parse($input))->toEqual([ - new ComponentNode($name, $prefix), + new ComponentNode($name, $prefix, $namespace), ]); })->with([ 'standard' => ['', 'x-', 'button'], 'short' => ['', 'x:', 'button'], 'namespaced' => ['', 'x-', 'ui::button'], - 'flux' => ['', 'flux:', 'flux::button'], + 'flux' => ['', 'flux:', 'flux::button', 'flux::'], ]); +test('parses custom registered prefixes', function () { + Blaze::registerPrefix('tk:', namespace: 'tk::'); + + expect(app(Parser::class)->parse(''))->toEqual([ + new ComponentNode('tk::icon', 'tk:', 'tk::', selfClosing: true), + ]); +}); + test('preprocesses attributes using Laravel pipeline', function ($input, $expected) { $result = app(Parser::class)->parse($input); diff --git a/tests/Parser/PrefixRegistryTest.php b/tests/Parser/PrefixRegistryTest.php new file mode 100644 index 00000000..dc241c94 --- /dev/null +++ b/tests/Parser/PrefixRegistryTest.php @@ -0,0 +1,38 @@ +all())->toBe([ + 'flux:' => ['namespace' => 'flux::', 'slot' => 'x-slot'], + 'x:' => ['namespace' => '', 'slot' => 'x-slot'], + 'x-' => ['namespace' => '', 'slot' => 'x-slot'], + ]); +}); + +test('registers a new prefix', function () { + $registry = new PrefixRegistry; + + $registry->register('tk:', 'tk::'); + + expect($registry->all())->toHaveKey('tk:', ['namespace' => 'tk::', 'slot' => 'x-slot']); +}); + +test('registers a new prefix with a custom slot prefix', function () { + $registry = new PrefixRegistry; + + $registry->register('tk:', 'tk::', 'tk-slot'); + + expect($registry->all())->toHaveKey('tk:', ['namespace' => 'tk::', 'slot' => 'tk-slot']); +}); + +test('overwrites an existing prefix without duplicating it', function () { + $registry = new PrefixRegistry; + + $registry->register('x-', 'custom::'); + + expect($registry->all())->toHaveCount(3) + ->and($registry->all())->toHaveKey('x-', ['namespace' => 'custom::', 'slot' => 'x-slot']); +}); diff --git a/tests/Parser/TokenizerTest.php b/tests/Parser/TokenizerTest.php index f47b8d30..9fe6a4a4 100644 --- a/tests/Parser/TokenizerTest.php +++ b/tests/Parser/TokenizerTest.php @@ -1,5 +1,6 @@ register('tk:', 'tk::'); + + $input = ''; + + $result = app(Tokenizer::class)->tokenize($input); + + expect($result)->toEqual([ + new TagOpenToken(name: 'icon', prefix: 'tk:', namespace: 'tk::'), + new TagCloseToken(name: 'icon', prefix: 'tk:', namespace: 'tk::'), + ]); +}); + test('tokenizes self-closing tags', function () { $input = '';