From a54de99a36d384ae30167331f60518db79d4e4a8 Mon Sep 17 00:00:00 2001 From: Filip Ganyicz Date: Thu, 6 Aug 2026 10:47:28 +0200 Subject: [PATCH 01/56] Refactor Tokenizer --- src/Parser/Tokenizer.php | 581 +++++------------------ src/Parser/TokenizerState.php | 17 - src/Parser/Tokens/ClosingTagToken.php | 14 + src/Parser/Tokens/OpeningTagToken.php | 16 + src/Parser/Tokens/PhpBlockToken.php | 13 + src/Parser/Tokens/SlotCloseToken.php | 14 - src/Parser/Tokens/SlotOpenToken.php | 16 - src/Parser/Tokens/TagCloseToken.php | 15 - src/Parser/Tokens/TagOpenToken.php | 16 - src/Parser/Tokens/TagSelfCloseToken.php | 16 - src/Parser/Tokens/VerbatimBlockToken.php | 13 + src/Support/LaravelRegex.php | 38 ++ tests/Parser/TokenizerTest.php | 135 +++--- 13 files changed, 279 insertions(+), 625 deletions(-) delete mode 100644 src/Parser/TokenizerState.php create mode 100644 src/Parser/Tokens/ClosingTagToken.php create mode 100644 src/Parser/Tokens/OpeningTagToken.php create mode 100644 src/Parser/Tokens/PhpBlockToken.php delete mode 100644 src/Parser/Tokens/SlotCloseToken.php delete mode 100644 src/Parser/Tokens/SlotOpenToken.php delete mode 100644 src/Parser/Tokens/TagCloseToken.php delete mode 100644 src/Parser/Tokens/TagOpenToken.php delete mode 100644 src/Parser/Tokens/TagSelfCloseToken.php create mode 100644 src/Parser/Tokens/VerbatimBlockToken.php diff --git a/src/Parser/Tokenizer.php b/src/Parser/Tokenizer.php index f96efbf4..59eb2bc9 100644 --- a/src/Parser/Tokenizer.php +++ b/src/Parser/Tokenizer.php @@ -5,14 +5,13 @@ use Illuminate\Support\Str; use Livewire\Blaze\BladeService; use Livewire\Blaze\Parser\Tokens\DirectiveToken; -use Livewire\Blaze\Parser\Tokens\TagSelfCloseToken; -use Livewire\Blaze\Parser\Tokens\SlotCloseToken; -use Livewire\Blaze\Parser\Tokens\SlotOpenToken; -use Livewire\Blaze\Parser\Tokens\TagCloseToken; -use Livewire\Blaze\Parser\Tokens\TagOpenToken; +use Livewire\Blaze\Parser\Tokens\OpeningTagToken; use Livewire\Blaze\Parser\Tokens\TextToken; use Livewire\Blaze\Parser\Tokens\Token; use Livewire\Blaze\Support\LaravelRegex; +use Livewire\Blaze\Parser\Tokens\PhpBlockToken; +use Livewire\Blaze\Parser\Tokens\ClosingTagToken; +use Livewire\Blaze\Parser\Tokens\VerbatimBlockToken; /** * Finite state machine that lexes Blade templates into component/slot/text tokens. @@ -24,38 +23,11 @@ public function __construct( ) { } - 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; - - protected int $length = 0; - protected array $tokens = []; - protected string $buffer = ''; - - protected ?Token $currentToken = null; - - protected array $tagStack = []; - - protected string $currentPrefix = ''; - - protected string $currentSlotPrefix = ''; + protected string $content; + protected int $position; + protected int $length; /** * Tokenize a Blade template into an array of tokens. @@ -64,316 +36,133 @@ public function tokenize(string $template): array { $this->tokens = []; $this->buffer = ''; - $this->currentToken = null; - $this->tagStack = []; - $this->currentPrefix = ''; - $this->currentSlotPrefix = ''; - - $state = TokenizerState::TEXT; foreach (token_get_all($template) as $token) { if (is_array($token) && $token[0] === T_INLINE_HTML) { - $this->position = 0; - $this->content = $token[1]; - $this->length = strlen($token[1]); - - while (!$this->isAtEnd()) { - $state = match ($state) { - TokenizerState::TEXT => $this->handleTextState(), - TokenizerState::TAG_OPEN => $this->handleTagOpenState(), - TokenizerState::TAG_CLOSE => $this->handleTagCloseState(), - TokenizerState::SLOT_OPEN => $this->handleSlotOpenState(), - TokenizerState::SLOT_CLOSE => $this->handleSlotCloseState(), - TokenizerState::SHORT_SLOT => $this->handleShortSlotState(), - TokenizerState::DIRECTIVE => $this->handleDirectiveState(), - default => throw new \RuntimeException("Unknown state: $state"), - }; - } - } else { - // If we hit a non-HTML code inside a tag token, we should discard that token - // and consider everything buffered so far as plain text. - $this->currentToken = null; + $this->flushBuffer(PhpBlockToken::class); + $this->tokenizeString($token[1]); - $state = TokenizerState::TEXT; - - $this->buffer .= is_array($token) ? $token[1] : $token; + continue; } + + $this->buffer .= is_array($token) ? $token[1] : $token; } - $this->flushBuffer(); + $this->flushBuffer(PhpBlockToken::class); return $this->tokens; } - /** - * Process text state, detecting component/slot tag boundaries. - */ - protected function handleTextState(): TokenizerState + protected function tokenizeString(string $content): void { - $char = $this->current(); - - if ($char === '<') { - $this->flushBuffer(); - - if ($slotInfo = $this->matchSlotOpen()) { - $this->currentSlotPrefix = $slotInfo['prefix']; - - if ($slotInfo['isShort']) { - $this->currentToken = new SlotOpenToken(slotStyle: 'short', prefix: $slotInfo['prefix']); - - return TokenizerState::SHORT_SLOT; - } else { - $this->currentToken = new SlotOpenToken(slotStyle: 'standard', prefix: $slotInfo['prefix']); - - return TokenizerState::SLOT_OPEN; - } - } - - if ($slotInfo = $this->matchSlotClose()) { - $this->currentToken = new SlotCloseToken(); - - $this->currentSlotPrefix = $slotInfo['prefix']; - - if ($this->current() === ':') { - $this->advance(); - } - - return TokenizerState::SLOT_CLOSE; - } - - if ($prefixInfo = $this->matchComponentOpen()) { - $this->currentPrefix = $prefixInfo['prefix']; - - $this->currentToken = new TagOpenToken( - name: '', - prefix: $prefixInfo['prefix'], - namespace: $prefixInfo['namespace'] - ); - - return TokenizerState::TAG_OPEN; - } - - if ($this->peek(1) === '/' && ($prefixInfo = $this->matchComponentClose())) { - $this->currentPrefix = $prefixInfo['prefix']; - - $this->currentToken = new TagCloseToken( - name: '', - prefix: $prefixInfo['prefix'], - namespace: $prefixInfo['namespace'] - ); - - return TokenizerState::TAG_CLOSE; - } - } - - if ($char === '@') { - // Skip escaped directives like `@@if` - if ($this->peek(1) === '@') { - $this->advance(2); - - return TokenizerState::TEXT; - } - - // Skip @ preceded by a word char like `info@example` - if ($this->position > 0 && preg_match('/\w/', $this->content[$this->position - 1])) { - $this->advance(); - - return TokenizerState::TEXT; - } - - $this->flushBuffer(); - - $this->currentToken = new DirectiveToken(name: '', original: ''); + $this->buffer = ''; + $this->position = 0; + $this->content = $content; + $this->length = strlen($content); - return TokenizerState::DIRECTIVE; + while (! $this->isAtEnd()) { + $this->process(); } - $this->advance(); - - return TokenizerState::TEXT; + $this->flushBuffer(); } - /** - * Process tag open state, extracting the component name and attributes. - */ - protected function handleTagOpenState(): TokenizerState + protected function process(): void { - if ($name = $this->matchTagName()) { - $this->currentToken->name = $name; - - $this->tagStack[] = $name; + if ($this->startsWith('@php') && ($match = $this->matchDirective()) && ! $match['expression']) { + $offset = $this->position; - $this->advance(strlen($name)); - - $this->collectAttributes(); - - if ($this->current() === '/' && $this->peek() === '>') { - $this->currentToken = new TagSelfCloseToken( - name: $this->currentToken->name, - prefix: $this->currentToken->prefix, - namespace: $this->currentToken->namespace, - attributes: $this->currentToken->attributes, - ); + $this->flushBuffer(); - array_pop($this->tagStack); + $this->advance(strlen('@php')); - $this->advance(2); + if ($this->advanceUntil('@endphp', fn () => $this->matchDirective())) { + $this->advance(strlen('@endphp')); - $this->emitToken(); + $this->flushBuffer(PhpBlockToken::class); + } else { + $this->emitToken(new DirectiveToken($match['name'], $match['original'])); - return TokenizerState::TEXT; + $this->rewind($offset + strlen($match['original'])); } - if ($this->current() === '>') { - $this->advance(); - - $this->emitToken(); - - return TokenizerState::TEXT; - } + return; } - $this->advance(); - - return TokenizerState::TAG_OPEN; - } - - /** - * Process closing tag state, extracting the component name. - */ - protected function handleTagCloseState(): TokenizerState - { - if ($name = $this->matchTagName()) { - $this->currentToken->name = $name; - - array_pop($this->tagStack); + if ($this->startsWith('@verbatim') && ($match = $this->matchDirective()) && ! $match['expression']) { + $offset = $this->position; - $this->advance(strlen($name)); - } - - if ($this->current() === '>') { - $this->advance(); - - $this->emitToken(); - - return TokenizerState::TEXT; - } - - $this->advance(); - - return TokenizerState::TAG_CLOSE; - } - - /** - * Process standard slot tag state. - */ - protected function handleSlotOpenState(): TokenizerState - { - $this->collectAttributes(); + $this->flushBuffer(); - // Extract and remove the name attribute from the collected attributes. - foreach ($this->currentToken->attributes as $i => $attr) { - if (preg_match('/^name="([^"]+)"$/', $attr, $matches)) { - $this->currentToken->name = $matches[1]; + $this->advance(strlen('@verbatim')); - unset($this->currentToken->attributes[$i]); + if ($this->advanceUntil('@endverbatim', fn () => $this->matchDirective())) { + $this->advance(strlen('@endverbatim')); - $this->currentToken->attributes = array_values($this->currentToken->attributes); + $this->flushBuffer(VerbatimBlockToken::class); + } else { + $this->emitToken(new DirectiveToken($match['name'], $match['original'])); - break; + $this->rewind($offset + strlen($match['original'])); } - } - - if ($this->current() === '>') { - $this->advance(); - $this->emitToken(); - - return TokenizerState::TEXT; + return; } - $this->advance(); - - return TokenizerState::SLOT_OPEN; - } - - /** - * Process closing slot tag state. - */ - protected function handleSlotCloseState(): TokenizerState - { - if ($name = $this->matchSlotName()) { - $this->currentToken->name = $name; - - $this->advance(strlen($name)); - } + if ($this->current() === '@' && $match = $this->matchDirective()) { + $this->flushBuffer(); - if ($this->current() === '>') { - $this->advance(); + $this->advance(strlen($match['original'])); - $this->emitToken(); + $this->emitToken(new DirectiveToken( + name: $match['name'], + original: $match['original'], + expression: $match['expression'], + )); - return TokenizerState::TEXT; + return; } - $this->advance(); + if ($this->current() === '<' && $match = $this->matchOpeningTag()) { + $this->flushBuffer(); - return TokenizerState::SLOT_CLOSE; - } + $this->advance(strlen($match['original'])); - /** - * Process short slot syntax state (). - */ - protected function handleShortSlotState(): TokenizerState - { - if ($name = $this->matchSlotName()) { - $this->currentToken->name = $name; + $this->emitToken(new OpeningTagToken($match['name'], $match['attributes'], $match['original'], $match['selfClosing'])); - $this->advance(strlen($name)); + return; + } - $this->collectAttributes(); + if ($this->current() === '<' && $this->peek() === '/' && $match = $this->matchClosingTag()) { + $this->flushBuffer(); - if ($this->current() === '>') { - $this->advance(); + $this->advance(strlen($match['original'])); - $this->emitToken(); + $this->emitToken(new ClosingTagToken($match['name'], $match['original'])); - return TokenizerState::TEXT; - } + return; } - $this->advance(); - - return TokenizerState::SHORT_SLOT; + $this->advanceUntilNext('<@'); } /** - * Process directive state, extracting the directive name and expression. + * Match a Blade directive at the current position. */ - protected function handleDirectiveState(): TokenizerState + protected function matchDirective(): ?array { - if (! $match = $this->matchDirective()) { - $this->advance(); + // Skip escaped directives like `@@if` + if ($this->peek(1) === '@') { + $this->advance(2); - return TokenizerState::TEXT; + return null; } - $this->advance(strlen($match['original'])); - - $this->currentToken->name = $match['name']; - $this->currentToken->original = $match['original']; - $this->currentToken->expression = $match['expression']; - - $this->emitToken(); - - return TokenizerState::TEXT; - } + // Skip @ preceded by a word char like `info@example` + if ($this->position > 0 && preg_match('/\w/', $this->content[$this->position - 1])) { + return null; + } - /** - * Match a Blade directive at the current position. - */ - protected function matchDirective(): ?array - { /** * The following code matches the parenthesis handling in Blade as closely as possible. * @@ -423,215 +212,97 @@ protected function matchDirective(): ?array ]; } - /** - * Collect all attributes on the current token, splitting on unquoted/unbracketed whitespace. - * Stops at > or /> without consuming them. - */ - protected function collectAttributes(): void + protected function matchOpeningTag(): array|null { - $attrString = ''; - $inSingleQuote = false; - $inDoubleQuote = false; - $braceCount = 0; - $bracketCount = 0; - $parenCount = 0; - - while (!$this->isAtEnd()) { - $char = $this->current(); - - $prevChar = $this->position > 0 ? $this->content[$this->position - 1] : ''; - - if ($char === '"' && !$inSingleQuote && $prevChar !== '\\') { - $inDoubleQuote = !$inDoubleQuote; - } elseif ($char === "'" && !$inDoubleQuote && $prevChar !== '\\') { - $inSingleQuote = !$inSingleQuote; - } + $pattern = "/^<\s*((?:x[-:]|flux:)[\w\-:.]*)". LaravelRegex::ATTRIBUTES ."(?\/?)>/x"; - if (!$inSingleQuote && !$inDoubleQuote) { - match($char) { - '{' => $braceCount++, - '}' => $braceCount--, - '[' => $bracketCount++, - ']' => $bracketCount--, - '(' => $parenCount++, - ')' => $parenCount--, - default => null - }; - } - - $isNested = $inSingleQuote || $inDoubleQuote - || $braceCount > 0 || $bracketCount > 0 || $parenCount > 0; - - // Tag end — flush and stop (don't consume). - if (($char === '>' || ($char === '/' && $this->peek() === '>')) && !$isNested) { - break; - } - - // Space outside nesting — flush current attribute and skip. - if ($char === ' ' && !$isNested) { - if ($attrString !== '') { - $this->currentToken->attributes[] = $attrString; - - $attrString = ''; - } + preg_match($pattern, $this->remaining(), $matches); - $this->advance(); - - continue; - } - - $attrString .= $char; - - $this->advance(); - } - - if ($attrString !== '') { - $this->currentToken->attributes[] = $attrString; - } - } - - /** - * Try to match a slot opening tag at the current position. - */ - protected function matchSlotOpen(): ?array - { - foreach ($this->prefixes as $prefix => $config) { - $slotPrefix = $config['slot']; - - if ($this->match('<\s*' . $slotPrefix . ':')) { - return ['prefix' => $slotPrefix, 'isShort' => true]; - } - - if ($this->match('<\s*' . $slotPrefix . '(?!:)')) { - return ['prefix' => $slotPrefix, 'isShort' => false]; - } + if ($matches) { + return [ + 'original' => $matches[0], + 'name' => $matches[1], + 'attributes' => $matches['attributes'], + 'selfClosing' => $matches['selfClosing'] === '/', + ]; } return null; } - /** - * Try to match a slot closing tag at the current position. - */ - protected function matchSlotClose(): ?array + protected function matchClosingTag(): array|null { - foreach ($this->prefixes as $prefix => $config) { - $slotPrefix = $config['slot']; + $pattern = "/^<\/\s*((?:x[-:]|flux:)[\w\-\:\.]*)\s*>/x"; - if ($this->match('<\/\s*' . $slotPrefix)) { - return ['prefix' => $slotPrefix]; - } + preg_match($pattern, $this->remaining(), $matches); + + if ($matches) { + return [ + 'original' => $matches[0], + 'name' => $matches[1], + ]; } return null; } /** - * Try to match a component opening tag at the current position. + * Get the character at the current position. */ - protected function matchComponentOpen(): ?array + protected function current(): string { - foreach ($this->prefixes as $prefix => $config) { - if ($this->match('<\s*' . $prefix)) { - return [ - 'prefix' => $prefix, - 'namespace' => $config['namespace'] ?? '', - ]; - } - } - - return null; + return $this->isAtEnd() ? '' : $this->content[$this->position]; } /** - * Try to match a component closing tag at the current position. + * Get the remaining content from the current position. */ - protected function matchComponentClose(): ?array + protected function remaining(): string { - foreach ($this->prefixes as $prefix => $config) { - if ($this->match('<\/\s*' . $prefix)) { - return [ - 'prefix' => $prefix, - 'namespace' => $config['namespace'] ?? '', - ]; - } - } - - return null; + return substr($this->content, $this->position); } /** - * Match a tag name at the current position. + * Peek at a character at an offset from the current position. */ - protected function matchTagName(): ?string + protected function peek(int $offset = 1): string { - if (preg_match(LaravelRegex::TAG_NAME, $this->remaining(), $matches)) { - return $matches[0]; - } + $pos = $this->position + $offset; - return null; + return $pos >= $this->length ? '' : $this->content[$pos]; } /** - * Match a slot name (alphanumeric, hyphens) at the current position. + * Advance the position by a number of characters. */ - protected function matchSlotName(): ?string + protected function advance(int $count = 1): void { - if (preg_match(LaravelRegex::SLOT_INLINE_NAME, $this->remaining(), $matches)) { - return $matches[0]; - } + $this->buffer .= substr($this->content, $this->position, $count); - return null; + $this->position += $count; } - /** - * Match a pattern at the current position and advance past it. - */ - protected function match(string $pattern): bool + protected function advanceUntil(string $str, ?callable $condition = null): bool { - if (preg_match('/^' . $pattern . '/', $this->remaining(), $matches)) { - $this->advance(strlen($matches[0])); + while (! $this->isAtEnd()) { + $this->advanceUntilNext($str[0]); - return true; + if ($this->startsWith($str) && (is_null($condition) || $condition())) { + return true; + } } return false; } - /** - * Get the character at the current position. - */ - protected function current(): string - { - return $this->isAtEnd() ? '' : $this->content[$this->position]; - } - - /** - * Peek at a character at an offset from the current position. - */ - protected function peek(int $offset = 1): string - { - $pos = $this->position + $offset; - - return $pos >= $this->length ? '' : $this->content[$pos]; - } - - /** - * Get the remaining content from the current position. - */ - protected function remaining(): string + protected function advanceUntilNext(string $characters): void { - return substr($this->content, $this->position); + $this->advance(strcspn($this->content, $characters, $this->position + 1) + 1); } - /** - * Advance the position by a number of characters. - */ - protected function advance(int $count = 1): void + protected function startsWith(string $str): bool { - $this->buffer .= substr($this->content, $this->position, $count); - - $this->position += $count; + return substr_compare($this->content, $str, $this->position, strlen($str)) === 0; } /** @@ -645,20 +316,26 @@ protected function isAtEnd(): bool /** * Emit the current token and discard the raw buffer. */ - protected function emitToken(): void + protected function emitToken(Token $token): void { - $this->tokens[] = $this->currentToken; + $this->tokens[] = $token; + + $this->buffer = ''; + } + protected function rewind(int $position): void + { + $this->position = $position; $this->buffer = ''; } /** - * Emit any accumulated text buffer as a TextToken. + * Emit any accumulated buffer as a given token. */ - protected function flushBuffer(): void + protected function flushBuffer(string $class = TextToken::class): void { if ($this->buffer !== '') { - $this->tokens[] = new TextToken($this->buffer); + $this->tokens[] = new $class($this->buffer); $this->buffer = ''; } diff --git a/src/Parser/TokenizerState.php b/src/Parser/TokenizerState.php deleted file mode 100644 index e0f41502..00000000 --- a/src/Parser/TokenizerState.php +++ /dev/null @@ -1,17 +0,0 @@ -). + */ +class ClosingTagToken extends Token +{ + public function __construct( + public string $name, + public string $original, + ) {} +} diff --git a/src/Parser/Tokens/OpeningTagToken.php b/src/Parser/Tokens/OpeningTagToken.php new file mode 100644 index 00000000..b9e87cee --- /dev/null +++ b/src/Parser/Tokens/OpeningTagToken.php @@ -0,0 +1,16 @@ +). - */ -class SlotCloseToken extends Token -{ - public function __construct( - public ?string $name = null, - public string $prefix = 'x-', - ) {} -} diff --git a/src/Parser/Tokens/SlotOpenToken.php b/src/Parser/Tokens/SlotOpenToken.php deleted file mode 100644 index 0f2bbe09..00000000 --- a/src/Parser/Tokens/SlotOpenToken.php +++ /dev/null @@ -1,16 +0,0 @@ - or ). - */ -class SlotOpenToken extends Token -{ - public function __construct( - public ?string $name = null, - public array $attributes = [], - public string $slotStyle = 'standard', - public string $prefix = 'x-', - ) {} -} diff --git a/src/Parser/Tokens/TagCloseToken.php b/src/Parser/Tokens/TagCloseToken.php deleted file mode 100644 index 29bcfebb..00000000 --- a/src/Parser/Tokens/TagCloseToken.php +++ /dev/null @@ -1,15 +0,0 @@ -). - */ -class TagCloseToken extends Token -{ - public function __construct( - public string $name, - public string $prefix, - public string $namespace = '', - ) {} -} diff --git a/src/Parser/Tokens/TagOpenToken.php b/src/Parser/Tokens/TagOpenToken.php deleted file mode 100644 index 8fb89b85..00000000 --- a/src/Parser/Tokens/TagOpenToken.php +++ /dev/null @@ -1,16 +0,0 @@ -). - */ -class TagOpenToken extends Token -{ - public function __construct( - public string $name, - public string $prefix, - public string $namespace = '', - public array $attributes = [], - ) {} -} diff --git a/src/Parser/Tokens/TagSelfCloseToken.php b/src/Parser/Tokens/TagSelfCloseToken.php deleted file mode 100644 index fe9fae52..00000000 --- a/src/Parser/Tokens/TagSelfCloseToken.php +++ /dev/null @@ -1,16 +0,0 @@ -). - */ -class TagSelfCloseToken extends Token -{ - public function __construct( - public string $name, - public string $prefix, - public string $namespace = '', - public array $attributes = [], - ) {} -} diff --git a/src/Parser/Tokens/VerbatimBlockToken.php b/src/Parser/Tokens/VerbatimBlockToken.php new file mode 100644 index 00000000..dc4ad123 --- /dev/null +++ b/src/Parser/Tokens/VerbatimBlockToken.php @@ -0,0 +1,13 @@ + + (?: + \s+ + (?: + (?: + @(?:class)(\( (?: (?>[^()]+) | (?-1) )* \)) + ) + | + (?: + @(?:style)(\( (?: (?>[^()]+) | (?-1) )* \)) + ) + | + (?: + \{\{\s*\\\$attributes(?:[^}]+?)?\s*\}\} + ) + | + (?: + (\:\\\$)(\w+) + ) + | + (?: + [\w\-:.@%]+ + ( + = + (?: + \\\"[^\\\"]*\\\" + | + \'[^\']*\' + | + [^\'\\\"=<>]+ + ) + )? + ) + ) + )* + \s* + )'; } diff --git a/tests/Parser/TokenizerTest.php b/tests/Parser/TokenizerTest.php index f47b8d30..6ba19e1b 100644 --- a/tests/Parser/TokenizerTest.php +++ b/tests/Parser/TokenizerTest.php @@ -1,65 +1,62 @@ '; +test('tokenizes php directive blocks', function () { + $input = '@php $i = 0; @endphp'; $result = app(Tokenizer::class)->tokenize($input); expect($result)->toEqual([ - new TagOpenToken(name: 'button', prefix: 'x-', attributes: ['type="button"']), - new TagCloseToken(name: 'button', prefix: 'x-'), + new PhpBlockToken($input) ]); }); -test('tokenizes self-closing tags', function () { - $input = ''; +test('tokenizes tags', function () { + $input = ''; $result = app(Tokenizer::class)->tokenize($input); expect($result)->toEqual([ - new TagSelfCloseToken(name: 'button', prefix: 'x-', attributes: ['type="button"']), + new OpeningTagToken(name: 'x-button', attributes: ' type="button"', original: '', selfClosing: false), + new ClosingTagToken(name: 'x-button', original: ''), ]); }); -test('tokenizes default slots', function () { - $input = ''; +test('tokenizes self-closing tags', function () { + $input = ''; $result = app(Tokenizer::class)->tokenize($input); expect($result)->toEqual([ - new SlotOpenToken(prefix: 'x-slot'), - new SlotCloseToken(prefix: 'x-'), + new OpeningTagToken(name: 'x-button', attributes: ' type="button" ', original: '', selfClosing: true), ]); }); -test('tokenizes standard slots', function () { - $input = ''; +test('tokenizes flux tags', function () { + $input = ''; $result = app(Tokenizer::class)->tokenize($input); expect($result)->toEqual([ - new SlotOpenToken(name: 'header', prefix: 'x-slot'), - new SlotCloseToken(prefix: 'x-'), + new OpeningTagToken(name: 'flux:button', attributes: ' type="button"', original: '', selfClosing: false), + new ClosingTagToken(name: 'flux:button', original: ''), ]); }); -test('tokenizes short slots', function () { - $input = ''; - - $result = app(Tokenizer::class)->tokenize($input); +test('only matches tags at the current position', function () { + $input = '< invalid '; - expect($result)->toEqual([ - new SlotOpenToken(name: 'header', slotStyle: 'short', prefix: 'x-slot', attributes: ['class="p-2"']), - new SlotCloseToken(name: 'header', prefix: 'x-'), + expect(app(Tokenizer::class)->tokenize($input))->toEqual([ + new TextToken('< invalid '), + new OpeningTagToken(name: 'x-button', attributes: '', original: '', selfClosing: false), + new TextToken(''), ]); }); @@ -83,85 +80,65 @@ ]); }); -test('handles whitespace in tags', function () { - $input = '< x-button >'; // This is valid Blade syntax... +test('tokenizes php blocks', function () { + $input = ' ?>'; $result = app(Tokenizer::class)->tokenize($input); expect($result)->toEqual([ - new TagOpenToken(name: 'button', prefix: 'x-'), - new TagCloseToken(name: 'button', prefix: 'x-'), + new OpeningTagToken(name: 'x-button', attributes: '', original: '', selfClosing: false), + new PhpBlockToken(content: ' ?>'), + new ClosingTagToken(name: 'x-button', original: ''), ]); }); -test('handles whitespace in slot tags', function () { - $input = '< x-slot:header >'; // This is valid Blade syntax... +test('handles unclosed php blocks', function () { + $input = ''; $result = app(Tokenizer::class)->tokenize($input); expect($result)->toEqual([ - new SlotOpenToken(name: 'header', slotStyle: 'short', prefix: 'x-slot'), - new SlotCloseToken(), + new PhpBlockToken(content: ''), ]); }); -test('handles whitespace in short slot tags', function () { - $input = '< x-slot:header >'; // This is valid Blade syntax... +test('handles Blade php blocks', function () { + $input = ' @php $value = ""; @endphp '; - $result = app(Tokenizer::class)->tokenize($input); - - expect($result)->toEqual([ - new SlotOpenToken(name: 'header', slotStyle: 'short', prefix: 'x-slot'), - new SlotCloseToken(name: 'header'), + expect(app(Tokenizer::class)->tokenize($input))->toEqual([ + new OpeningTagToken(name: 'x-button', attributes: '', original: '', selfClosing: false), + new TextToken(' '), + new PhpBlockToken(content: '@php $value = ""; @endphp'), + new TextToken(' '), + new ClosingTagToken(name: 'x-button', original: ''), ]); }); -test('handles attributes with angled brackets', function () { - $input = ''; - - $result = app(Tokenizer::class)->tokenize($input); - - expect($result)->toEqual([ - new TagOpenToken( - name: 'button', - prefix: 'x-', - attributes: [ - ':data="[\'foo\' => \'bar\']"', - ':callback="fn () => 0"', - ], +test('handles unclosed Blade php blocks', function () { + $input = '@php $value = "";'; + + expect(app(Tokenizer::class)->tokenize($input))->toEqual([ + new DirectiveToken(name: 'php', original: '@php '), // <-- TODO: weird whitespace + new TextToken(content: '$value = "'), + new OpeningTagToken( + name: 'x-button', + attributes: ' ', // <-- TODO: weird whitespace + original: '', + selfClosing: true, ), - ]); -}); - -test('handles php blocks', function () { - $input = ' ?>'; - - $result = app(Tokenizer::class)->tokenize($input); - - expect($result)->toEqual([ - new TagOpenToken(name: 'button', prefix: 'x-'), - new TextToken(content: ' ?>'), - new TagCloseToken(name: 'button', prefix: 'x-'), - ]); -}); - -test('handles unclosed php blocks', function () { - $input = ''; - - $result = app(Tokenizer::class)->tokenize($input); - - expect($result)->toEqual([ - new TextToken(content: ''), + new TextToken(content: '";'), ]); }); test('handles php blocks inside tags', function () { - $input = '>'; + $input = '>'; $result = app(Tokenizer::class)->tokenize($input); expect($result)->toEqual([ - new TextToken(content: '>'), + new TextToken(content: ''), + new TextToken(content: '>'), ]); }); From abcfc138603de1744155db7e5f18ff5083bde1e9 Mon Sep 17 00:00:00 2001 From: Filip Ganyicz Date: Thu, 6 Aug 2026 10:54:11 +0200 Subject: [PATCH 02/56] Formatting --- src/Parser/Tokenizer.php | 36 ++++++++++++++++++++---- src/Parser/Tokens/ClosingTagToken.php | 2 +- src/Parser/Tokens/OpeningTagToken.php | 2 +- src/Parser/Tokens/PhpBlockToken.php | 2 +- src/Parser/Tokens/VerbatimBlockToken.php | 2 +- src/Support/LaravelRegex.php | 6 ++++ 6 files changed, 40 insertions(+), 10 deletions(-) diff --git a/src/Parser/Tokenizer.php b/src/Parser/Tokenizer.php index 59eb2bc9..d5f60c3a 100644 --- a/src/Parser/Tokenizer.php +++ b/src/Parser/Tokenizer.php @@ -4,17 +4,17 @@ use Illuminate\Support\Str; use Livewire\Blaze\BladeService; +use Livewire\Blaze\Parser\Tokens\ClosingTagToken; use Livewire\Blaze\Parser\Tokens\DirectiveToken; use Livewire\Blaze\Parser\Tokens\OpeningTagToken; +use Livewire\Blaze\Parser\Tokens\PhpBlockToken; use Livewire\Blaze\Parser\Tokens\TextToken; use Livewire\Blaze\Parser\Tokens\Token; -use Livewire\Blaze\Support\LaravelRegex; -use Livewire\Blaze\Parser\Tokens\PhpBlockToken; -use Livewire\Blaze\Parser\Tokens\ClosingTagToken; use Livewire\Blaze\Parser\Tokens\VerbatimBlockToken; +use Livewire\Blaze\Support\LaravelRegex; /** - * Finite state machine that lexes Blade templates into component/slot/text tokens. + * Lexes Blade templates into tags, directives, PHP blocks, verbatim blocks, and text tokens. */ class Tokenizer { @@ -45,7 +45,7 @@ public function tokenize(string $template): array continue; } - $this->buffer .= is_array($token) ? $token[1] : $token; + $this->buffer .= is_array($token) ? $token[1] : $token; } $this->flushBuffer(PhpBlockToken::class); @@ -53,6 +53,9 @@ public function tokenize(string $template): array return $this->tokens; } + /** + * Tokenize a string of inline HTML content. + */ protected function tokenizeString(string $content): void { $this->buffer = ''; @@ -67,6 +70,9 @@ protected function tokenizeString(string $content): void $this->flushBuffer(); } + /** + * Process the token starting at the current position. + */ protected function process(): void { if ($this->startsWith('@php') && ($match = $this->matchDirective()) && ! $match['expression']) { @@ -200,7 +206,7 @@ protected function matchDirective(): ?array $match[4] = $match[4].$rest; } - // No closing parenthesis found + // Reject matches that do not begin at the current position. if (! Str::startsWith($template, $match[0])) { return null; } @@ -212,6 +218,9 @@ protected function matchDirective(): ?array ]; } + /** + * Match an opening or self-closing component tag at the current position. + */ protected function matchOpeningTag(): array|null { $pattern = "/^<\s*((?:x[-:]|flux:)[\w\-:.]*)". LaravelRegex::ATTRIBUTES ."(?\/?)>/x"; @@ -230,6 +239,9 @@ protected function matchOpeningTag(): array|null return null; } + /** + * Match a closing component tag at the current position. + */ protected function matchClosingTag(): array|null { $pattern = "/^<\/\s*((?:x[-:]|flux:)[\w\-\:\.]*)\s*>/x"; @@ -282,6 +294,9 @@ protected function advance(int $count = 1): void $this->position += $count; } + /** + * Advance until a matching string satisfying the optional condition is found. + */ protected function advanceUntil(string $str, ?callable $condition = null): bool { while (! $this->isAtEnd()) { @@ -295,11 +310,17 @@ protected function advanceUntil(string $str, ?callable $condition = null): bool return false; } + /** + * Advance through the next occurrence of any of the given characters. + */ protected function advanceUntilNext(string $characters): void { $this->advance(strcspn($this->content, $characters, $this->position + 1) + 1); } + /** + * Determine whether the remaining content starts with the given string. + */ protected function startsWith(string $str): bool { return substr_compare($this->content, $str, $this->position, strlen($str)) === 0; @@ -323,6 +344,9 @@ protected function emitToken(Token $token): void $this->buffer = ''; } + /** + * Move to a position and discard the accumulated buffer. + */ protected function rewind(int $position): void { $this->position = $position; diff --git a/src/Parser/Tokens/ClosingTagToken.php b/src/Parser/Tokens/ClosingTagToken.php index 08523190..38bacde3 100644 --- a/src/Parser/Tokens/ClosingTagToken.php +++ b/src/Parser/Tokens/ClosingTagToken.php @@ -3,7 +3,7 @@ namespace Livewire\Blaze\Parser\Tokens; /** - * Represents a closing component tag (/>). + * Represents a closing component tag (). */ class ClosingTagToken extends Token { diff --git a/src/Parser/Tokens/OpeningTagToken.php b/src/Parser/Tokens/OpeningTagToken.php index b9e87cee..091fc496 100644 --- a/src/Parser/Tokens/OpeningTagToken.php +++ b/src/Parser/Tokens/OpeningTagToken.php @@ -3,7 +3,7 @@ namespace Livewire\Blaze\Parser\Tokens; /** - * Represents an opening component tag (...) + * @see ComponentTagCompiler::compileSelfClosingTags() — (?...) + */ const ATTRIBUTES = '(? (?: \s+ From e35bf7a5a13791a628745748d5a7291d2e3b8ecc Mon Sep 17 00:00:00 2001 From: Filip Ganyicz Date: Thu, 6 Aug 2026 12:22:53 +0200 Subject: [PATCH 03/56] Fix regex --- src/Support/LaravelRegex.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Support/LaravelRegex.php b/src/Support/LaravelRegex.php index ed733847..d15bf61a 100644 --- a/src/Support/LaravelRegex.php +++ b/src/Support/LaravelRegex.php @@ -87,7 +87,7 @@ class LaravelRegex * @see ComponentTagCompiler::compileOpeningTags() — (?...) * @see ComponentTagCompiler::compileSelfClosingTags() — (?...) */ - const ATTRIBUTES = '(? + const ATTRIBUTES = "(? (?: \s+ (?: @@ -123,5 +123,5 @@ class LaravelRegex ) )* \s* - )'; + )"; } From eb04c9e32c90c75072bdb34153c2435cdfcd254a Mon Sep 17 00:00:00 2001 From: Filip Ganyicz Date: Thu, 6 Aug 2026 12:51:37 +0200 Subject: [PATCH 04/56] Refactor Parser --- src/Parser/Nodes/PhpBlockNode.php | 19 ++++ src/Parser/Nodes/VerbatimBlockNode.php | 19 ++++ src/Parser/Parser.php | 129 ++++++++++++++----------- src/Parser/Tokenizer.php | 18 ++-- src/Parser/Tokens/ClosingTagToken.php | 1 + src/Parser/Tokens/OpeningTagToken.php | 11 +++ tests/Parser/ParserTest.php | 17 ++++ tests/Parser/TokenizerTest.php | 34 ++++--- 8 files changed, 169 insertions(+), 79 deletions(-) create mode 100644 src/Parser/Nodes/PhpBlockNode.php create mode 100644 src/Parser/Nodes/VerbatimBlockNode.php diff --git a/src/Parser/Nodes/PhpBlockNode.php b/src/Parser/Nodes/PhpBlockNode.php new file mode 100644 index 00000000..1a0d503b --- /dev/null +++ b/src/Parser/Nodes/PhpBlockNode.php @@ -0,0 +1,19 @@ +content; + } +} diff --git a/src/Parser/Nodes/VerbatimBlockNode.php b/src/Parser/Nodes/VerbatimBlockNode.php new file mode 100644 index 00000000..7fdf45f9 --- /dev/null +++ b/src/Parser/Nodes/VerbatimBlockNode.php @@ -0,0 +1,19 @@ +content; + } +} diff --git a/src/Parser/Parser.php b/src/Parser/Parser.php index 8f3751c5..8e78e77e 100644 --- a/src/Parser/Parser.php +++ b/src/Parser/Parser.php @@ -5,16 +5,16 @@ use Livewire\Blaze\BladeService; use Livewire\Blaze\Parser\Nodes\ComponentNode; use Livewire\Blaze\Parser\Nodes\DirectiveNode; +use Livewire\Blaze\Parser\Nodes\PhpBlockNode; use Livewire\Blaze\Parser\Nodes\SlotNode; use Livewire\Blaze\Parser\Nodes\TextNode; -use Livewire\Blaze\Parser\Tokenizer; +use Livewire\Blaze\Parser\Nodes\VerbatimBlockNode; +use Livewire\Blaze\Parser\Tokens\ClosingTagToken; use Livewire\Blaze\Parser\Tokens\DirectiveToken; -use Livewire\Blaze\Parser\Tokens\SlotCloseToken; -use Livewire\Blaze\Parser\Tokens\SlotOpenToken; -use Livewire\Blaze\Parser\Tokens\TagCloseToken; -use Livewire\Blaze\Parser\Tokens\TagOpenToken; -use Livewire\Blaze\Parser\Tokens\TagSelfCloseToken; +use Livewire\Blaze\Parser\Tokens\OpeningTagToken; +use Livewire\Blaze\Parser\Tokens\PhpBlockToken; use Livewire\Blaze\Parser\Tokens\TextToken; +use Livewire\Blaze\Parser\Tokens\VerbatimBlockToken; use Livewire\Blaze\Support\AttributeParser; /** @@ -39,13 +39,12 @@ public function parse(string $content): array foreach ($tokens as $token) { match(get_class($token)) { - TagOpenToken::class => $this->handleTagOpen($token, $stack), - TagSelfCloseToken::class => $this->handleTagSelfClose($token, $stack), - TagCloseToken::class => $this->handleTagClose($token, $stack), - SlotOpenToken::class => $this->handleSlotOpen($token, $stack), - SlotCloseToken::class => $this->handleSlotClose($token, $stack), + OpeningTagToken::class => $this->handleOpeningTag($token, $stack), + ClosingTagToken::class => $this->handleClosingTag($token, $stack), DirectiveToken::class => $this->handleDirective($token, $stack), TextToken::class => $this->handleText($token, $stack), + PhpBlockToken::class => $this->handlePhpBlock($token, $stack), + VerbatimBlockToken::class => $this->handleVerbatimBlock($token, $stack), default => throw new \RuntimeException('Unknown token type: ' . get_class($token)) }; } @@ -56,83 +55,75 @@ public function parse(string $content): array /** * Handle an opening component tag token. */ - protected function handleTagOpen(TagOpenToken $token, ParseStack $stack): void + protected function handleOpeningTag(OpeningTagToken $token, ParseStack $stack): void { - $attributeString = implode(' ', $token->attributes); + if ($token->isSlot()) { + $this->handleSlotOpen($token, $stack); - $node = new ComponentNode( - name: $token->namespace . $token->name, - prefix: $token->prefix, - attributeString: $attributeString, - children: [], - selfClosing: false, - attributes: $this->attributes->parse($attributeString), - ); - - $stack->pushContainer($node); - } - - /** - * Handle a self-closing component tag token. - */ - protected function handleTagSelfClose(TagSelfCloseToken $token, ParseStack $stack): void - { - $attributeString = implode(' ', $token->attributes); + return; + } $node = new ComponentNode( - name: $token->namespace . $token->name, + name: $token->name, prefix: $token->prefix, - attributeString: $attributeString, + attributeString: trim($token->attributes), children: [], - selfClosing: true, - attributes: $this->attributes->parse($attributeString), + selfClosing: $token->selfClosing, + attributes: $this->attributes->parse($token->attributes), ); - $stack->addToRoot($node); + if ($token->selfClosing) { + $stack->addToRoot($node); + } else { + $stack->pushContainer($node); + } } /** - * Handle a closing component tag token. + * Handle a closing component or slot tag token. */ - protected function handleTagClose(TagCloseToken $token, ParseStack $stack): void + protected function handleClosingTag(ClosingTagToken $token, ParseStack $stack): void { - $stack->popContainer(); + $closed = $stack->popContainer(); + + if ($closed instanceof SlotNode && $closed->slotStyle === 'short' && str_contains($token->name, ':')) { + $closed->closeHasName = true; + } } /** * Handle an opening slot tag token. */ - protected function handleSlotOpen(SlotOpenToken $token, ParseStack $stack): void + protected function handleSlotOpen(OpeningTagToken $token, ParseStack $stack): void { - $attributeString = implode(' ', $token->attributes); + $short = str_starts_with($token->name, 'slot:'); + + $attributeString = $token->attributes; + $attributes = $this->attributes->parse($token->attributes); + + $name = $short ? substr($token->name, strlen('slot:')) : ($attributes['name'] ?? 'slot'); + + if (! $short && isset($attributes['name'])) { + // TODO: We should be able to handle dynamic slot names... + $name = $attributes['name']; + $attributeString = trim(preg_replace('/(?:^|\s+)name\s*=\s*(["\']).*?\1/', '', $token->attributes, 1)); + + unset($attributes['name']); + } $node = new SlotNode( - name: $token->name ?? 'slot', + name: $name, attributeString: $attributeString, - slotStyle: $token->slotStyle, + slotStyle: $short ? 'short' : 'standard', children: [], - prefix: $token->prefix, + prefix: $token->prefix . 'slot' . ($short ? ':' : ''), closeHasName: false, - attributes: $this->attributes->parse($attributeString), + attributes: $attributes, ); $stack->pushContainer($node); } - /** - * Handle a closing slot tag token. - */ - protected function handleSlotClose(SlotCloseToken $token, ParseStack $stack): void - { - $closed = $stack->popContainer(); - if ($closed instanceof SlotNode && $closed->slotStyle === 'short') { - // If tokenizer captured a :name on the close tag, mark it - if (! empty($token->name)) { - $closed->closeHasName = true; - } - } - } - protected function handleDirective(DirectiveToken $token, ParseStack $stack): void { $node = new DirectiveNode( @@ -153,4 +144,24 @@ protected function handleText(TextToken $token, ParseStack $stack): void $stack->addToRoot($node); } + + /** + * Handle a PHP block token. + */ + protected function handlePhpBlock(PhpBlockToken $token, ParseStack $stack): void + { + $node = new PhpBlockNode(content: $token->content); + + $stack->addToRoot($node); + } + + /** + * Handle a verbatim block token. + */ + protected function handleVerbatimBlock(VerbatimBlockToken $token, ParseStack $stack): void + { + $node = new VerbatimBlockNode(content: $token->content); + + $stack->addToRoot($node); + } } diff --git a/src/Parser/Tokenizer.php b/src/Parser/Tokenizer.php index d5f60c3a..7a5bd904 100644 --- a/src/Parser/Tokenizer.php +++ b/src/Parser/Tokenizer.php @@ -60,8 +60,8 @@ protected function tokenizeString(string $content): void { $this->buffer = ''; $this->position = 0; - $this->content = $content; - $this->length = strlen($content); + $this->content = $this->blade->compileComments($content); + $this->length = strlen($this->content); while (! $this->isAtEnd()) { $this->process(); @@ -134,7 +134,7 @@ protected function process(): void $this->advance(strlen($match['original'])); - $this->emitToken(new OpeningTagToken($match['name'], $match['attributes'], $match['original'], $match['selfClosing'])); + $this->emitToken(new OpeningTagToken($match['prefix'], $match['name'], $match['attributes'], $match['original'], $match['selfClosing'])); return; } @@ -144,7 +144,7 @@ protected function process(): void $this->advance(strlen($match['original'])); - $this->emitToken(new ClosingTagToken($match['name'], $match['original'])); + $this->emitToken(new ClosingTagToken($match['prefix'], $match['name'], $match['original'])); return; } @@ -223,14 +223,15 @@ protected function matchDirective(): ?array */ protected function matchOpeningTag(): array|null { - $pattern = "/^<\s*((?:x[-:]|flux:)[\w\-:.]*)". LaravelRegex::ATTRIBUTES ."(?\/?)>/x"; + $pattern = "/^<\s*(x[-:]|flux:)([\w\-:.]*)". LaravelRegex::ATTRIBUTES ."(?\/?)>/x"; preg_match($pattern, $this->remaining(), $matches); if ($matches) { return [ 'original' => $matches[0], - 'name' => $matches[1], + 'prefix' => $matches[1], + 'name' => $matches[2], 'attributes' => $matches['attributes'], 'selfClosing' => $matches['selfClosing'] === '/', ]; @@ -244,14 +245,15 @@ protected function matchOpeningTag(): array|null */ protected function matchClosingTag(): array|null { - $pattern = "/^<\/\s*((?:x[-:]|flux:)[\w\-\:\.]*)\s*>/x"; + $pattern = "/^<\/\s*(x[-:]|flux:)([\w\-\:\.]*)\s*>/x"; preg_match($pattern, $this->remaining(), $matches); if ($matches) { return [ 'original' => $matches[0], - 'name' => $matches[1], + 'prefix' => $matches[1], + 'name' => $matches[2], ]; } diff --git a/src/Parser/Tokens/ClosingTagToken.php b/src/Parser/Tokens/ClosingTagToken.php index 38bacde3..7fa62baa 100644 --- a/src/Parser/Tokens/ClosingTagToken.php +++ b/src/Parser/Tokens/ClosingTagToken.php @@ -8,6 +8,7 @@ class ClosingTagToken extends Token { public function __construct( + public string $prefix, public string $name, public string $original, ) {} diff --git a/src/Parser/Tokens/OpeningTagToken.php b/src/Parser/Tokens/OpeningTagToken.php index 091fc496..78c20cdd 100644 --- a/src/Parser/Tokens/OpeningTagToken.php +++ b/src/Parser/Tokens/OpeningTagToken.php @@ -8,9 +8,20 @@ class OpeningTagToken extends Token { public function __construct( + public string $prefix, public string $name, public string $attributes, public string $original, public bool $selfClosing, ) {} + + public function isBladeComponent() + { + return in_array($this->prefix, ['x-', 'x:']); + } + + public function isSlot() + { + return $this->isBladeComponent() && $this->name === 'slot' || str_starts_with($this->name, 'slot:'); + } } diff --git a/tests/Parser/ParserTest.php b/tests/Parser/ParserTest.php index 4c214ee3..7c0e38e1 100644 --- a/tests/Parser/ParserTest.php +++ b/tests/Parser/ParserTest.php @@ -3,8 +3,10 @@ use Livewire\Blaze\Parser\Parser; use Livewire\Blaze\Parser\Nodes\ComponentNode; use Livewire\Blaze\Parser\Nodes\DirectiveNode; +use Livewire\Blaze\Parser\Nodes\PhpBlockNode; use Livewire\Blaze\Parser\Nodes\SlotNode; use Livewire\Blaze\Parser\Nodes\TextNode; +use Livewire\Blaze\Parser\Nodes\VerbatimBlockNode; use Livewire\Blaze\Support\AttributeParser; @@ -150,3 +152,18 @@ new DirectiveNode('csrf', $input), ]); }); + +test('parses PHP and verbatim blocks', function () { + $input = '@verbatim@endverbatim'; + + expect(app(Parser::class)->parse($input))->toEqual([ + new ComponentNode( + name: 'card', + prefix: 'x-', + children: [ + new PhpBlockNode(''), + new VerbatimBlockNode('@verbatim@endverbatim'), + ], + ), + ]); +}); diff --git a/tests/Parser/TokenizerTest.php b/tests/Parser/TokenizerTest.php index 6ba19e1b..e1707e63 100644 --- a/tests/Parser/TokenizerTest.php +++ b/tests/Parser/TokenizerTest.php @@ -23,8 +23,8 @@ $result = app(Tokenizer::class)->tokenize($input); expect($result)->toEqual([ - new OpeningTagToken(name: 'x-button', attributes: ' type="button"', original: '', selfClosing: false), - new ClosingTagToken(name: 'x-button', original: ''), + new OpeningTagToken(prefix: 'x-', name: 'button', attributes: ' type="button"', original: '', selfClosing: false), + new ClosingTagToken(prefix: 'x-', name: 'button', original: ''), ]); }); @@ -34,7 +34,7 @@ $result = app(Tokenizer::class)->tokenize($input); expect($result)->toEqual([ - new OpeningTagToken(name: 'x-button', attributes: ' type="button" ', original: '', selfClosing: true), + new OpeningTagToken(prefix: 'x-', name: 'button', attributes: ' type="button" ', original: '', selfClosing: true), ]); }); @@ -44,8 +44,8 @@ $result = app(Tokenizer::class)->tokenize($input); expect($result)->toEqual([ - new OpeningTagToken(name: 'flux:button', attributes: ' type="button"', original: '', selfClosing: false), - new ClosingTagToken(name: 'flux:button', original: ''), + new OpeningTagToken(prefix: 'flux:', name: 'button', attributes: ' type="button"', original: '', selfClosing: false), + new ClosingTagToken(prefix: 'flux:', name: 'button', original: ''), ]); }); @@ -54,9 +54,9 @@ expect(app(Tokenizer::class)->tokenize($input))->toEqual([ new TextToken('< invalid '), - new OpeningTagToken(name: 'x-button', attributes: '', original: '', selfClosing: false), + new OpeningTagToken(prefix: 'x-', name: 'button', attributes: '', original: '', selfClosing: false), new TextToken(''), + new ClosingTagToken(prefix: 'x-', name: 'button', original: ''), ]); }); @@ -86,9 +86,9 @@ $result = app(Tokenizer::class)->tokenize($input); expect($result)->toEqual([ - new OpeningTagToken(name: 'x-button', attributes: '', original: '', selfClosing: false), + new OpeningTagToken(prefix: 'x-', name: 'button', attributes: '', original: '', selfClosing: false), new PhpBlockToken(content: ' ?>'), - new ClosingTagToken(name: 'x-button', original: ''), + new ClosingTagToken(prefix: 'x-', name: 'button', original: ''), ]); }); @@ -106,11 +106,11 @@ $input = ' @php $value = ""; @endphp '; expect(app(Tokenizer::class)->tokenize($input))->toEqual([ - new OpeningTagToken(name: 'x-button', attributes: '', original: '', selfClosing: false), + new OpeningTagToken(prefix: 'x-', name: 'button', attributes: '', original: '', selfClosing: false), new TextToken(' '), new PhpBlockToken(content: '@php $value = ""; @endphp'), new TextToken(' '), - new ClosingTagToken(name: 'x-button', original: ''), + new ClosingTagToken(prefix: 'x-', name: 'button', original: ''), ]); }); @@ -121,7 +121,7 @@ new DirectiveToken(name: 'php', original: '@php '), // <-- TODO: weird whitespace new TextToken(content: '$value = "'), new OpeningTagToken( - name: 'x-button', + prefix: 'x-', name: 'button', attributes: ' ', // <-- TODO: weird whitespace original: '', selfClosing: true, @@ -271,3 +271,13 @@ new TextToken(content: '))'), ]); }); + +test('handles comments', function () { + $input = '{{-- Comment --}}'; + + $result = app(Tokenizer::class)->tokenize($input); + + expect($result)->toEqual([ + new OpeningTagToken(prefix: 'x-', name: 'button', attributes: ' ', original: '', selfClosing: true), + ]); +}); \ No newline at end of file From 9d4d4ad044df444df18ba1ae1e034a803ca29042 Mon Sep 17 00:00:00 2001 From: Filip Ganyicz Date: Thu, 6 Aug 2026 13:23:52 +0200 Subject: [PATCH 05/56] Fix tests --- src/Parser/Parser.php | 10 +++++----- tests/Parser/ParserTest.php | 14 ++++++++++++++ 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/src/Parser/Parser.php b/src/Parser/Parser.php index 8e78e77e..e1725686 100644 --- a/src/Parser/Parser.php +++ b/src/Parser/Parser.php @@ -64,7 +64,7 @@ protected function handleOpeningTag(OpeningTagToken $token, ParseStack $stack): } $node = new ComponentNode( - name: $token->name, + name: $token->prefix === 'flux:' ? 'flux::' . $token->name : $token->name, prefix: $token->prefix, attributeString: trim($token->attributes), children: [], @@ -105,18 +105,18 @@ protected function handleSlotOpen(OpeningTagToken $token, ParseStack $stack): vo if (! $short && isset($attributes['name'])) { // TODO: We should be able to handle dynamic slot names... - $name = $attributes['name']; - $attributeString = trim(preg_replace('/(?:^|\s+)name\s*=\s*(["\']).*?\1/', '', $token->attributes, 1)); + $name = $attributes['name']->value; + $attributeString = preg_replace('/(?:^|\s+)name\s*=\s*(["\']).*?\1/', '', $token->attributes, 1); unset($attributes['name']); } $node = new SlotNode( name: $name, - attributeString: $attributeString, + attributeString: trim($attributeString), slotStyle: $short ? 'short' : 'standard', children: [], - prefix: $token->prefix . 'slot' . ($short ? ':' : ''), + prefix: $token->prefix . 'slot', closeHasName: false, attributes: $attributes, ); diff --git a/tests/Parser/ParserTest.php b/tests/Parser/ParserTest.php index 7c0e38e1..362fb43c 100644 --- a/tests/Parser/ParserTest.php +++ b/tests/Parser/ParserTest.php @@ -24,6 +24,20 @@ ]); }); +test('parses flux components', function () { + $input = ''; + + expect(app(Parser::class)->parse($input))->toEqual([ + new ComponentNode( + name: 'flux::button', + prefix: 'flux:', + selfClosing: true, + attributeString: 'class="my-4"', + attributes: app(AttributeParser::class)->parse('class="my-4"'), + ), + ]); +}); + test('parses named slots', function () { $input = 'Footer'; From 15daf17576d46d8c5b40e8dec6b5047b921db370 Mon Sep 17 00:00:00 2001 From: Filip Ganyicz Date: Thu, 6 Aug 2026 13:24:18 +0200 Subject: [PATCH 06/56] Stop storing uncompiled blocks --- src/BlazeManager.php | 18 +----------------- tests/BlazeManagerTest.php | 30 ++++++++++++++++++++++++++---- 2 files changed, 27 insertions(+), 21 deletions(-) diff --git a/src/BlazeManager.php b/src/BlazeManager.php index 5b001b35..376f12e0 100644 --- a/src/BlazeManager.php +++ b/src/BlazeManager.php @@ -8,9 +8,7 @@ use Livewire\Blaze\Compiler\Wrapper; use Livewire\Blaze\Compiler\Compiler; use Livewire\Blaze\Compiler\Profiler; -use Livewire\Blaze\Memoizer\Memo; use Livewire\Blaze\Runtime\BlazeRuntime; -use Livewire\Blaze\Directive\BlazeDirective; use Livewire\Blaze\Events\ComponentFolded; use Livewire\Blaze\Folder\Folder; use Livewire\Blaze\Memoizer\Memoizer; @@ -71,8 +69,6 @@ public function compile(string $template, ?string $path = null): string $source = $template; $clean = $template; - $clean = $this->blade->preStoreUncompiledBlocks($clean); - $clean = $this->blade->compileComments($clean); $dataStack = []; @@ -125,8 +121,6 @@ public function compile(string $template, ?string $path = null): string $output = $this->instrumenter->profileView($output, $path, $source); } - $output = $this->blade->restoreRawBlocks($output); - return $output; } @@ -135,9 +129,6 @@ public function compile(string $template, ?string $path = null): string */ public function compileForUnblaze(string $template): string { - $template = $this->blade->preStoreUncompiledBlocks($template); - $template = $this->blade->compileComments($template); - $ast = $this->walker->walk( nodes: $this->parser->parse($template), preCallback: fn ($node) => $node, @@ -176,12 +167,8 @@ public function compileForDebug(string $template, ?string $path = null): string { $source = $template; - $clean = $template; - $clean = $this->blade->preStoreUncompiledBlocks($clean); - $clean = $this->blade->compileComments($clean); - $ast = $this->walker->walk( - nodes: $this->parser->parse($clean), + nodes: $this->parser->parse($template), preCallback: fn ($node) => $node, postCallback: function ($node) { if (! ($node instanceof ComponentNode)) { @@ -211,9 +198,6 @@ public function compileForFolding(string $template, ?string $path = null): strin { $source = $template; - $template = $this->blade->preStoreUncompiledBlocks($template); - $template = $this->blade->compileComments($template); - $ast = $this->walker->walk( nodes: $this->parser->parse($template), preCallback: fn ($node) => $node, diff --git a/tests/BlazeManagerTest.php b/tests/BlazeManagerTest.php index 52a5947d..702591b7 100644 --- a/tests/BlazeManagerTest.php +++ b/tests/BlazeManagerTest.php @@ -12,24 +12,46 @@ expect(Blaze::compile($input))->toBe($input); }); +test('compile preserves verbatim directives', function () { + $input = '@verbatim @endverbatim'; + + expect(Blaze::compile($input))->toBe($input); +}); + test('compileForDebug preserves php directives', function () { $input = '@php /* uncompiled */ @endphp'; expect(Blaze::compileForDebug($input))->toBe($input); }); +test('compileForDebug preserves verbatim directives', function () { + $input = '@verbatim @endverbatim'; + + expect(Blaze::compileForDebug($input))->toBe($input); +}); + test('compileForFolding preserves php directives', function () { $input = '@php /* uncompiled */ @endphp'; expect(Blaze::compileForFolding($input))->toBe($input); }); -test('compileForUnblaze does not restore raw blocks', function () { +test('compileForFolding preserves verbatim directives', function () { + $input = '@verbatim @endverbatim'; + + expect(Blaze::compileForFolding($input))->toBe($input); +}); + +test('compileForUnblaze preserves php directives', function () { $input = '@php /* uncompiled */ @endphp'; - // compileForUnblaze should only store raw blocks, not restore them. - // They will be restored in the parent compile() method. - expect(Blaze::compileForUnblaze($input))->toBe('@__raw_block_0__@'); + expect(Blaze::compileForUnblaze($input))->toBe($input); +}); + +test('compileForUnblaze preserves verbatim directives', function () { + $input = '@verbatim @endverbatim'; + + expect(Blaze::compileForUnblaze($input))->toBe($input); }); test('viewContainsExpiredFrontMatter returns true when folded component source is updated', function () { From 8241166db9e2bd24f6a00e302529ec7e14a75bd2 Mon Sep 17 00:00:00 2001 From: Filip Ganyicz Date: Thu, 6 Aug 2026 14:45:50 +0200 Subject: [PATCH 07/56] Refactor Wrapper to use AST --- src/BlazeManager.php | 18 +--- src/Compiler/Wrapper.php | 163 ++++++++++++++++------------- src/Parser/Nodes/DirectiveNode.php | 2 +- src/Parser/Nodes/Node.php | 49 +++++++++ src/Parser/Walker.php | 20 +++- tests/Compiler/WrapperTest.php | 42 +++++--- tests/Parser/ParserTest.php | 10 +- 7 files changed, 203 insertions(+), 101 deletions(-) diff --git a/src/BlazeManager.php b/src/BlazeManager.php index 376f12e0..1da8c057 100644 --- a/src/BlazeManager.php +++ b/src/BlazeManager.php @@ -66,14 +66,10 @@ public function __construct( */ public function compile(string $template, ?string $path = null): string { - $source = $template; - - $clean = $template; - $dataStack = []; $ast = $this->walker->walk( - nodes: $this->parser->parse($clean), + nodes: $this->parser->parse($template), preCallback: function ($node) use (&$dataStack) { if ($node instanceof ComponentNode && $node->children) { $dataStack[] = $node->attributes; @@ -113,12 +109,12 @@ public function compile(string $template, ?string $path = null): string $output = $this->render($ast); - $directives = new Directives($source); + $directives = new Directives($template); if ($path && ($directives->blaze() || $this->config->shouldCompile($path))) { - $output = $this->wrapper->wrap($output, $path, $source); + $output = $this->render($this->wrapper->wrap($ast, $path)); } elseif ($this->isDebugging() && ! $this->isFolding() && $path) { - $output = $this->instrumenter->profileView($output, $path, $source); + $output = $this->instrumenter->profileView($output, $path, $template); } return $output; @@ -185,8 +181,6 @@ public function compileForDebug(string $template, ?string $path = null): string $output = $this->instrumenter->profileView($output, $path, $source); } - $output = $this->blade->restoreRawBlocks($output); - return $output; } @@ -208,8 +202,6 @@ public function compileForFolding(string $template, ?string $path = null): strin $output = $this->render($ast); - $output = $this->blade->restoreRawBlocks($output); - if (! $path) { return $output; } @@ -220,7 +212,7 @@ public function compileForFolding(string $template, ?string $path = null): strin || $this->config->shouldCompile($path); if ($directives->blaze() || $shouldWrap) { - $output = $this->wrapper->wrap($output, $path, $source); + $output = $this->render($this->wrapper->wrap($ast, $path)); } return $output; diff --git a/src/Compiler/Wrapper.php b/src/Compiler/Wrapper.php index 12484ec3..f5c8b289 100644 --- a/src/Compiler/Wrapper.php +++ b/src/Compiler/Wrapper.php @@ -4,9 +4,10 @@ use Livewire\Blaze\BladeService; use Livewire\Blaze\BlazeManager; -use Livewire\Blaze\Compiler\DirectiveCompiler; use Livewire\Blaze\Support\Utils; -use Illuminate\Support\Arr; +use Livewire\Blaze\Parser\Nodes\DirectiveNode; +use Livewire\Blaze\Parser\Nodes\PhpBlockNode; +use Livewire\Blaze\Parser\Walker; /** * Compiles Blaze component templates into PHP function definitions. @@ -29,102 +30,121 @@ public function __construct( /** * Compile a component template into a function definition. * - * @param string $compiled The compiled template (after TagCompiler processing) + * @param array<\Livewire\Blaze\Parser\Nodes\Node> $ast The template AST * @param string $path The component file path - * @param string|null $source The original source template (for detecting $slot usage) */ - public function wrap(string $compiled, string $path, ?string $source = null): string + public function wrap(array $ast, string $path): array { - $source ??= $compiled; $name = ($this->manager->isFolding() ? '__' : '_') . Utils::hash($path); - $sourceUsesThis = str_contains($source, '$this') || str_contains($compiled, '@entangle') || str_contains($compiled, '@script') || str_contains($compiled, '@assets'); - - $compiled = $this->blade->compileUseStatements($compiled); - $compiled = $this->blade->restoreRawBlocks($compiled); - $compiled = $this->blade->storeVerbatimBlocks($compiled); + $sourceUsesThis = $this->usesThis($ast); $imports = ''; - - $compiled = $this->useExtractor->extract($compiled, function ($statement) use (&$imports) { - $imports .= $statement . "\n"; - }); - $compiled = $this->blade->preStoreUncompiledBlocks($compiled); + $ast = (new Walker)->walk( + nodes: $ast, + preCallback: function ($node) { + if ($node instanceof DirectiveNode && $node->name === 'use') { + return new PhpBlockNode($this->blade->compileUseStatements($node->original)); + } - $output = ''; + return $node; + }, + postCallback: function ($node) use (&$imports) { + if ($node instanceof PhpBlockNode) { + return new PhpBlockNode( + $this->useExtractor->extract($node->content, function ($statement) use (&$imports) { + $imports .= $statement . "\n"; + }) + ); + } - $output .= '<'.'?php' . "\n"; - $output .= $imports; - $output .= 'if (!function_exists(\''.$name.'\')):'."\n"; - $output .= 'function '.$name.'($__blaze, $__data = [], $__slots = [], $__bound = [], $__keys = [], $__this = null) {'."\n"; + if ($node instanceof DirectiveNode && $node->name === 'props') { + return new PhpBlockNode($this->propsCompiler->compile($node->expression)); + } - if ($sourceUsesThis) { - $output .= '$__blazeFn = function () use ($__blaze, $__data, $__slots, $__bound, $__keys) {'."\n"; - } + if ($node instanceof DirectiveNode && $node->name === 'aware') { + return new PhpBlockNode($this->awareCompiler->compile($node->expression)); + } - $output .= $this->globalVariables($source, $compiled); - $output .= 'if (($__data[\'attributes\'] ?? null) instanceof \Illuminate\View\ComponentAttributeBag) { $__data = $__data + $__data[\'attributes\']->all(); unset($__data[\'attributes\']); }'."\n"; - $output .= 'extract($__slots, EXTR_SKIP); unset($__slots);'."\n"; - $output .= 'extract($__data, EXTR_SKIP);'."\n"; - $output .= '$attributes = \\Livewire\\Blaze\\Runtime\\BlazeAttributeBag::make($__data, $__bound, $__keys);'."\n"; - $output .= 'unset($__data, $__bound, $__keys);'."\n"; - $output .= 'ob_start();' . "\n"; - $output .= '?>' . "\n"; + return $node; + } + ); + + $opening = ''; - $compiled = DirectiveCompiler::make() - ->directive('props', $this->propsCompiler->compile(...)) - ->directive('aware', $this->awareCompiler->compile(...)) - ->compile($compiled); + $opening .= '<'.'?php' . "\n"; + $opening .= $imports; + $opening .= 'if (!function_exists(\''.$name.'\')):'."\n"; + $opening .= 'function '.$name.'($__blaze, $__data = [], $__slots = [], $__bound = [], $__keys = [], $__this = null) {'."\n"; - $compiled = $this->blade->restoreRawBlocks($compiled); + if ($sourceUsesThis) { + $opening .= '$__blazeFn = function () use ($__blaze, $__data, $__slots, $__bound, $__keys) {'."\n"; + } - $output .= $compiled; + $opening .= $this->globalVariables($ast); + $opening .= 'if (($__data[\'attributes\'] ?? null) instanceof \Illuminate\View\ComponentAttributeBag) { $__data = $__data + $__data[\'attributes\']->all(); unset($__data[\'attributes\']); }'."\n"; + $opening .= 'extract($__slots, EXTR_SKIP); unset($__slots);'."\n"; + $opening .= 'extract($__data, EXTR_SKIP);'."\n"; + $opening .= '$attributes = \\Livewire\\Blaze\\Runtime\\BlazeAttributeBag::make($__data, $__bound, $__keys);'."\n"; + $opening .= 'unset($__data, $__bound, $__keys);'."\n"; + $opening .= 'ob_start();' . "\n"; + $opening .= '?>' . "\n"; - $output .= 'manager->isFolding() ? '$__blaze->processPassthroughContent(\'ltrim\', ltrim(ob_get_clean()))' : 'ltrim(ob_get_clean())'; - $output .= 'echo ' . $contentHandler . ';' . "\n"; + $closing .= 'echo ' . $contentHandler . ';' . "\n"; if ($sourceUsesThis) { - $output .= '}; if ($__this !== null) { $__blazeFn->call($__this); } else { $__blazeFn(); }'."\n"; + $closing .= '}; if ($__this !== null) { $__blazeFn->call($__this); } else { $__blazeFn(); }'."\n"; } - $output .= '} endif; ?>'; + $closing .= '} endif; ?>'; - return $output; + return [ + new PhpBlockNode($opening), + ...$ast, + new PhpBlockNode($closing), + ]; } - protected function globalVariables(string $source, string $compiled): string + protected function globalVariables(array $ast): string { - $output = ''; + $variables = [ + '$__env' => '$__env = $__blaze->env', + ]; - $output .= '$__env = $__blaze->env;' . "\n"; + $hasEchoHandlers = $this->hasEchoHandlers(); - if ($this->hasEchoHandlers() && ($this->hasEchoSyntax($source) || $this->hasEchoSyntax($compiled))) { - $output .= '$__bladeCompiler = app(\'blade.compiler\');' . "\n"; - } + foreach ((new Walker)->iterate($ast) as $node) { + if (! isset($variables['$app']) && $node->usesVariable('$app')) { + $variables['$app'] = '$app = $__blaze->app'; + } - $output .= implode("\n", array_filter(Arr::map([ - [['$app'], '$app = $__blaze->app;'], - [['$errors', '@error'], '$errors = $__blaze->errors;'], - [['$__livewire', '@entangle', '@this'], '$__livewire = $__env->shared(\'__livewire\');'], - [['@this'], '$_instance = $__livewire;'], - [['$slot'], '$__slots[\'slot\'] ??= new \Illuminate\View\ComponentSlot(\'\');'], - ], function ($data) use ($source, $compiled) { - [$patterns, $variable] = $data; - - foreach ($patterns as $pattern) { - if (str_contains($source, $pattern) || str_contains($compiled, $pattern)) { - return $variable; - } + if (! isset($variables['$errors']) && ($node->usesVariable('$errors') || $node->isDirective('error'))) { + $variables['$errors'] = '$errors = $__blaze->errors'; } - return null; - }))) . "\n"; + if (! isset($variables['$__livewire']) && ($node->usesVariable('$__livewire') || $node->isDirective('entangle') || $node->isDirective('this'))) { + $variables['$__livewire'] = '$__livewire = $__env->shared(\'__livewire\')'; + } + + if (! isset($variables['$_instance']) && $node->isDirective('this')) { + $variables['$_instance'] = '$_instance = $__livewire'; + } - return $output; + if (! isset($variables['$slot']) && $node->usesVariable('$slot')) { + $variables['$slot'] = '$__slots[\'slot\'] ??= new \Illuminate\View\ComponentSlot(\'\')'; + } + + if ($hasEchoHandlers && ! isset($variables['$__bladeCompiler']) && $node->usesEchoSyntax()) { + $variables['$__bladeCompiler'] = '$__bladeCompiler = app(\'blade.compiler\')'; + } + } + + return join(";\n", $variables) . ";\n"; } /** @@ -138,11 +158,14 @@ protected function hasEchoHandlers(): bool return ! empty($reflection->getValue($compiler)); } - /** - * Check if the source contains Blade echo syntax. - */ - protected function hasEchoSyntax(string $source): bool + protected function usesThis(array $ast): bool { - return preg_match('/\{\{.+?\}\}|\{!!.+?!!\}/s', $source) === 1; + foreach ((new Walker)->iterate($ast) as $node) { + if ($node->usesVariable('$this') || $node->isDirective(['entangle', 'script', 'assets'])) { + return true; + } + } + + return false; } } diff --git a/src/Parser/Nodes/DirectiveNode.php b/src/Parser/Nodes/DirectiveNode.php index 9213016f..9711c3ca 100644 --- a/src/Parser/Nodes/DirectiveNode.php +++ b/src/Parser/Nodes/DirectiveNode.php @@ -2,7 +2,7 @@ namespace Livewire\Blaze\Parser\Nodes; -class DirectiveNode extends TextNode +class DirectiveNode extends Node { public function __construct( public string $name, diff --git a/src/Parser/Nodes/Node.php b/src/Parser/Nodes/Node.php index bc5f3462..86f5701f 100644 --- a/src/Parser/Nodes/Node.php +++ b/src/Parser/Nodes/Node.php @@ -11,4 +11,53 @@ abstract class Node * Render this node to its string output. */ abstract public function render(): string; + + public function usesVariable(string $variable): bool + { + // TODO: for TextNode we should check variables inside {{ }} + if ($this instanceof PhpBlockNode || $this instanceof TextNode) { + if (str_contains($this->content, $variable)) { + return true; + } + } + + if ($this instanceof ComponentNode || $this instanceof SlotNode) { + if (str_contains($this->attributeString, $variable)) { + return true; + } + } + + if ($this instanceof DirectiveNode) { + if (str_contains($this->expression, $variable)) { + return true; + } + } + + return false; + } + + public function usesEchoSyntax(): bool + { + if ($this instanceof TextNode) { + if (preg_match('/\{\{.+?\}\}|\{!!.+?!!\}/s', $this->content) === 1) { + return true; + } + } + + if ($this instanceof ComponentNode || $this instanceof SlotNode) { + if (preg_match('/\{\{.+?\}\}|\{!!.+?!!\}/s', $this->attributeString) === 1) { + return true; + } + } + + return false; + } + + public function isDirective(string|array $name): bool + { + $names = is_array($name) ? $name : [$name]; + $names = array_map(fn ($s) => strtolower($s), $names); + + return $this instanceof DirectiveNode && in_array(strtolower($this->name), $names); + } } diff --git a/src/Parser/Walker.php b/src/Parser/Walker.php index efb26eab..939dc51a 100644 --- a/src/Parser/Walker.php +++ b/src/Parser/Walker.php @@ -18,17 +18,31 @@ public function walk(array $nodes, callable $preCallback, callable $postCallback $result = []; foreach ($nodes as $node) { - $processed = $preCallback($node); + $node = $preCallback($node) ?? $node; if (($node instanceof ComponentNode || $node instanceof SlotNode) && !empty($node->children)) { $node->children = $this->walk($node->children, $preCallback, $postCallback); } - $processed = $postCallback($node); + $node = $postCallback($node) ?? $node; - $result[] = $processed ?? $node; + $result[] = $node; } return $result; } + + /** + * @return \Generator + */ + public function iterate(array $nodes): \Generator + { + foreach ($nodes as $node) { + yield spl_object_id($node) => $node; + + if (($node instanceof ComponentNode || $node instanceof SlotNode) && $node->children) { + yield from $this->iterate($node->children); + } + } + } } diff --git a/tests/Compiler/WrapperTest.php b/tests/Compiler/WrapperTest.php index 83c50895..dec35814 100644 --- a/tests/Compiler/WrapperTest.php +++ b/tests/Compiler/WrapperTest.php @@ -4,13 +4,15 @@ use Livewire\Blaze\Compiler\Wrapper; use Illuminate\Support\Facades\Blade; use Livewire\Blaze\BladeService; +use Livewire\Blaze\Parser\Parser; test('wraps component templates into function definitions', function () { $path = fixture_path('views/components/input.blade.php'); $source = file_get_contents($path); $hash = Utils::hash($path); - $wrapped = app(Wrapper::class)->wrap($source, $path, $source); + $ast = app(Parser::class)->parse($source); + $wrapped = join('', array_map(fn ($node) => $node->render(), app(Wrapper::class)->wrap($ast, $path))); expect($wrapped)->toEqualCollapsingWhitespace(join('', [ 'wrap($source, $path, $source); + $ast = app(Parser::class)->parse($source); + $wrapped = join('', array_map(fn ($node) => $node->render(), app(Wrapper::class)->wrap($ast, $path))); expect($wrapped)->toEqualCollapsingWhitespace(join('', [ 'wrap('
', ''))->toContain('extract($__data, EXTR_SKIP);'); + $ast = app(Parser::class)->parse('
'); + $wrapped = join('', array_map(fn ($node) => $node->render(), app(Wrapper::class)->wrap($ast, ''))); + + expect($wrapped)->toContain('extract($__data, EXTR_SKIP);'); }); test('wraps in self invoking closure', function ($source) { - expect(app(Wrapper::class)->wrap($source, ''))->toContain( + $ast = app(Parser::class)->parse($source); + $wrapped = join('', array_map(fn ($node) => $node->render(), app(Wrapper::class)->wrap($ast, ''))); + + expect($wrapped)->toContain( '$__blazeFn = function () use ($__blaze, $__data, $__slots, $__bound, $__keys) {', 'if ($__this !== null) { $__blazeFn->call($__this); } else { $__blazeFn(); }', ); @@ -77,8 +86,10 @@ ]); test('injects variables', function ($source, $expected) { - expect(app(Wrapper::class)->wrap('', '', $source))->toContain($expected); - expect(app(Wrapper::class)->wrap($source, '', ''))->toContain($expected); + $ast = app(Parser::class)->parse($source); + $wrapped = join('', array_map(fn ($node) => $node->render(), app(Wrapper::class)->wrap($ast, ''))); + + expect($wrapped)->toContain($expected); })->with([ 'errors' => ['{{ $errors->has(\'name\') }}', '$errors = $__blaze->errors;'], 'errors directive' => ['', '$errors = $__blaze->errors;'], @@ -92,14 +103,17 @@ test('injects echo handler', function () { Blade::stringable((new class {})::class, fn () => 'dummy'); - expect(app(Wrapper::class)->wrap('{{ $a }}', ''))->toContain('$__bladeCompiler = app(\'blade.compiler\');'); + $ast = app(Parser::class)->parse('{{ $a }}'); + $wrapped = join('', array_map(fn ($node) => $node->render(), app(Wrapper::class)->wrap($ast, ''))); + + expect($wrapped)->toContain('$__bladeCompiler = app(\'blade.compiler\');'); }); test('hoists use statements to top of output', function ($statement) { - // Replace raw @php blocks for placeholders. This normally happens in BlazeManager before the template gets to the Wrapper - $source = app(BladeService::class)->preStoreUncompiledBlocks($statement); + $ast = app(Parser::class)->parse($statement); + $wrapped = join('', array_map(fn ($node) => $node->render(), app(Wrapper::class)->wrap($ast, ''))); - expect(app(Wrapper::class)->wrap($source, '', $source))->toStartWith("toStartWith("with([ ['@use(\'App\Models\User\')'], ['@php use \App\Models\User; @endphp'], @@ -108,12 +122,16 @@ test('preserves php directives', function () { $input = '@php /* uncompiled */ @endphp'; + $ast = app(Parser::class)->parse($input); + $wrapped = join('', array_map(fn ($node) => $node->render(), app(Wrapper::class)->wrap($ast, ''))); - expect(app(Wrapper::class)->wrap($input, ''))->toContain($input); + expect($wrapped)->toContain($input); }); test('preserves verbatim directives', function () { $input = '@verbatim /* uncompiled */ @endverbatim'; + $ast = app(Parser::class)->parse($input); + $wrapped = join('', array_map(fn ($node) => $node->render(), app(Wrapper::class)->wrap($ast, ''))); - expect(app(Wrapper::class)->wrap($input, ''))->toContain($input); + expect($wrapped)->toContain($input); }); diff --git a/tests/Parser/ParserTest.php b/tests/Parser/ParserTest.php index 362fb43c..2b85173a 100644 --- a/tests/Parser/ParserTest.php +++ b/tests/Parser/ParserTest.php @@ -8,6 +8,7 @@ use Livewire\Blaze\Parser\Nodes\TextNode; use Livewire\Blaze\Parser\Nodes\VerbatimBlockNode; use Livewire\Blaze\Support\AttributeParser; +use Livewire\Blaze\Parser\Walker; test('parses self-closing components', function () { @@ -168,15 +169,20 @@ }); test('parses PHP and verbatim blocks', function () { - $input = '@verbatim@endverbatim'; + $input = ' @verbatim @endverbatim @php echo "footer"; @endphp '; expect(app(Parser::class)->parse($input))->toEqual([ new ComponentNode( name: 'card', prefix: 'x-', children: [ + new TextNode(' '), new PhpBlockNode(''), - new VerbatimBlockNode('@verbatim@endverbatim'), + new TextNode(' '), + new VerbatimBlockNode('@verbatim @endverbatim'), + new TextNode(' '), + new PhpBlockNode('@php echo "footer"; @endphp'), + new TextNode(' '), ], ), ]); From ee9326e1ca4f3bd798b4c58d99b9e8a56cacf728 Mon Sep 17 00:00:00 2001 From: Filip Ganyicz Date: Thu, 6 Aug 2026 14:47:46 +0200 Subject: [PATCH 08/56] Remove unused methods --- src/BladeService.php | 74 +++------------------------------------- src/Compiler/Wrapper.php | 2 +- 2 files changed, 5 insertions(+), 71 deletions(-) diff --git a/src/BladeService.php b/src/BladeService.php index 31af937a..63a2001b 100644 --- a/src/BladeService.php +++ b/src/BladeService.php @@ -49,70 +49,6 @@ public function earliestPreCompilationHook(callable $callback): void }); } - /** - * Invoke the Blade compiler's storeUncompiledBlocks via reflection. - */ - public function preStoreUncompiledBlocks(string $input): string - { - $output = $input; - - $output = $this->storeVerbatimBlocks($output); - $output = $this->storePhpBlocks($output); - - return $output; - } - - /** - * Store only @verbatim blocks as raw block placeholders. - */ - public function storeVerbatimBlocks(string $input): string - { - return $this->storeRawBlock(LaravelRegex::VERBATIM_BLOCK, $input); - } - - /** - * Store only @verbatim blocks as raw block placeholders. - */ - public function storePhpBlocks(string $input): string - { - return $this->storeRawBlock(LaravelRegex::PHP_BLOCK, $input); - } - - /** - * Store a raw block placeholder via the Blade compiler. - */ - protected function storeRawBlock(string $pattern, string $content): string - { - $reflection = new \ReflectionClass($this->compiler); - $method = $reflection->getMethod('storeRawBlock'); - - return preg_replace_callback($pattern, function ($matches) use ($method) { - return $method->invoke($this->compiler, $matches[0]); - }, $content); - } - - /** - * Restore raw block placeholders to their original content. - */ - public function restoreRawBlocks(string $input): string - { - $reflection = new \ReflectionClass($this->compiler); - $method = $reflection->getMethod('restoreRawContent'); - - return $method->invoke($this->compiler, $input); - } - - /** - * Restore raw block placeholders to their original content. - */ - public function restorePhpBlocks(string $input): string - { - $reflection = new \ReflectionClass($this->compiler); - $method = $reflection->getMethod('restorePhpBlocks'); - - return $method->invoke($this->compiler, $input); - } - /** * Invoke the Blade compiler's compileComments via reflection. */ @@ -162,14 +98,12 @@ public function preprocessAttributeString(string $attributeString): string })->call($this->tagCompiler, $attributeString); } - public function compileUseStatements(string $input): string + public function compileUseStatements(string $expression): string { - return DirectiveCompiler::make()->directive('use', function ($expression) { - $reflection = new \ReflectionClass($this->compiler); - $method = $reflection->getMethod('compileUse'); + $reflection = new \ReflectionClass($this->compiler); + $method = $reflection->getMethod('compileUse'); - return $method->invoke($this->compiler, $expression); - })->compile($input); + return $method->invoke($this->compiler, $expression); } /** diff --git a/src/Compiler/Wrapper.php b/src/Compiler/Wrapper.php index f5c8b289..d3adc955 100644 --- a/src/Compiler/Wrapper.php +++ b/src/Compiler/Wrapper.php @@ -45,7 +45,7 @@ public function wrap(array $ast, string $path): array nodes: $ast, preCallback: function ($node) { if ($node instanceof DirectiveNode && $node->name === 'use') { - return new PhpBlockNode($this->blade->compileUseStatements($node->original)); + return new PhpBlockNode($this->blade->compileUseStatements($node->expression)); } return $node; From 4e886397743857efbd5f1a68885831821b338463 Mon Sep 17 00:00:00 2001 From: Filip Ganyicz Date: Thu, 6 Aug 2026 16:58:12 +0200 Subject: [PATCH 09/56] Refactor ComponentSource to use AST --- src/BladeRenderer.php | 3 +- src/BlazeManager.php | 15 ++++--- src/BlazeServiceProvider.php | 2 + src/Compiler/Compiler.php | 4 +- src/Compiler/Profiler.php | 21 ++------- src/Folder/Folder.php | 9 ++-- src/Memoizer/Memoizer.php | 5 +-- src/Parser/Walker.php | 11 +++++ src/Runtime/BlazeRuntime.php | 48 +++++++++----------- src/Support/ComponentRepository.php | 38 ++++++++++++++++ src/Support/ComponentSource.php | 52 +++++++-------------- src/Support/Directives.php | 70 +++-------------------------- tests/Folder/FoldableTest.php | 24 +++++----- tests/Folder/UnblazeTest.php | 8 ++-- tests/Runtime/BlazeRuntimeTest.php | 13 ++++++ tests/Support/DirectivesTest.php | 26 ++++++----- 16 files changed, 162 insertions(+), 187 deletions(-) create mode 100644 src/Support/ComponentRepository.php diff --git a/src/BladeRenderer.php b/src/BladeRenderer.php index 89301e0e..8f3c50f8 100644 --- a/src/BladeRenderer.php +++ b/src/BladeRenderer.php @@ -6,15 +6,14 @@ use Illuminate\Support\Arr; use Illuminate\Support\Facades\File; use Illuminate\View\Compilers\BladeCompiler; -use Illuminate\View\Component; use Illuminate\View\ComponentSlot; use Livewire\Blaze\Parser\Attribute; use Livewire\Blaze\Parser\Nodes\ComponentNode; use Livewire\Blaze\Parser\Nodes\SlotNode; use Livewire\Blaze\Runtime\BlazeRuntime; -use Livewire\Blaze\Support\ComponentSource; use Livewire\Blaze\Support\Utils; use ReflectionClass; +use Livewire\Blaze\Support\ComponentSource; /** * Handles isolated Blade rendering used during compile-time folding. diff --git a/src/BlazeManager.php b/src/BlazeManager.php index 1da8c057..c0232369 100644 --- a/src/BlazeManager.php +++ b/src/BlazeManager.php @@ -13,13 +13,14 @@ use Livewire\Blaze\Folder\Folder; use Livewire\Blaze\Memoizer\Memoizer; use Livewire\Blaze\Parser\Nodes\ComponentNode; +use Livewire\Blaze\Parser\Nodes\DirectiveNode; use Livewire\Blaze\Parser\Parser; use Livewire\Blaze\Parser\Tokenizer; use Livewire\Blaze\Parser\Walker; use Livewire\Blaze\Support\Directives; -use Livewire\Blaze\Support\ComponentSource; use Livewire\Blaze\Parser\Nodes\SlotNode; use Livewire\Blaze\Support\AttributeParser; +use Livewire\Blaze\Support\ComponentRepository; class BlazeManager { @@ -46,6 +47,7 @@ public function __construct( protected BladeCompiler $bladeCompiler, protected BlazeRuntime $runtime, protected BladeService $blade, + protected ComponentRepository $components, ) { $this->renderer = new BladeRenderer($bladeCompiler, app('view'), $this->runtime, $this); $this->parser = new Parser(new Tokenizer($this->blade), new AttributeParser($this->blade)); @@ -54,7 +56,7 @@ public function __construct( $this->folder = new Folder($config, $this->blade, $this->renderer, $this); $this->memoizer = new Memoizer($config, $this->compiler, $this->blade, $this); $this->wrapper = new Wrapper($this->blade, $this); - $this->instrumenter = new Profiler($config, $this->blade); + $this->instrumenter = new Profiler($config, $this->blade, $this); Event::listen(ComponentFolded::class, function (ComponentFolded $event) { $this->foldedEvents[] = $event; @@ -109,7 +111,7 @@ public function compile(string $template, ?string $path = null): string $output = $this->render($ast); - $directives = new Directives($template); + $directives = new Directives($this->walker->filter($ast, fn ($n) => $n instanceof DirectiveNode)); if ($path && ($directives->blaze() || $this->config->shouldCompile($path))) { $output = $this->render($this->wrapper->wrap($ast, $path)); @@ -206,7 +208,10 @@ public function compileForFolding(string $template, ?string $path = null): strin return $output; } - $directives = new Directives($source); + $directives = new Directives( + (new Walker)->filter($ast, fn ($node) => $node instanceof DirectiveNode) + ); + $shouldWrap = $this->config->shouldFold($path) || $this->config->shouldMemoize($path) || $this->config->shouldCompile($path); @@ -380,7 +385,7 @@ protected function hasAwareDescendant(ComponentNode|SlotNode $node): bool { foreach ($node->children as $child) { if ($child instanceof ComponentNode) { - $source = ComponentSource::for($this->blade->componentNameToPath($child->name)); + $source = $this->components->get($child->name); if (str_ends_with($child->name, 'delegate-component')) { return true; diff --git a/src/BlazeServiceProvider.php b/src/BlazeServiceProvider.php index 10b8d9a2..b05ada43 100644 --- a/src/BlazeServiceProvider.php +++ b/src/BlazeServiceProvider.php @@ -12,6 +12,7 @@ use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\View; use Livewire\Blaze\Memoizer\Memo; +use Livewire\Blaze\Support\ComponentRepository; class BlazeServiceProvider extends ServiceProvider { @@ -25,6 +26,7 @@ public function register(): void $this->app->singleton(Debugger::class); $this->app->singleton(Profiler::class); $this->app->singleton(BlazeManager::class); + $this->app->singleton(ComponentRepository::class); $this->app->singleton(\PhpParser\Parser::class, function () { return (new \PhpParser\ParserFactory)->createForNewestSupportedVersion(); diff --git a/src/Compiler/Compiler.php b/src/Compiler/Compiler.php index 91bd8d8a..b70e27e5 100644 --- a/src/Compiler/Compiler.php +++ b/src/Compiler/Compiler.php @@ -40,9 +40,9 @@ public function compile(Node $node): Node return new TextNode($this->compileDelegateComponentTag($node)); } - $source = ComponentSource::for($this->blade->componentNameToPath($node->name)); + $source = $this->manager->components->get($node->name); - if (! $source->exists()) { + if (! $source) { return $node; } diff --git a/src/Compiler/Profiler.php b/src/Compiler/Profiler.php index ff161bfa..be073929 100644 --- a/src/Compiler/Profiler.php +++ b/src/Compiler/Profiler.php @@ -3,11 +3,11 @@ namespace Livewire\Blaze\Compiler; use Livewire\Blaze\BladeService; +use Livewire\Blaze\BlazeManager; use Livewire\Blaze\Config; use Livewire\Blaze\Parser\Nodes\ComponentNode; use Livewire\Blaze\Parser\Nodes\Node; use Livewire\Blaze\Parser\Nodes\TextNode; -use Livewire\Blaze\Support\ComponentSource; /** * Wraps every component's compiled output with profiler timer calls. @@ -23,6 +23,7 @@ class Profiler public function __construct( protected Config $config, protected BladeService $blade, + protected BlazeManager $manager, ) { } @@ -31,14 +32,14 @@ public function __construct( */ public function profile(Node $node, string $componentName, ?string $strategy = null): Node { - $source = ComponentSource::for($this->blade->componentNameToPath($componentName)); + $source = $this->manager->components->get($componentName); if ($strategy === null) { $isBlade = $node instanceof ComponentNode; $strategy = $isBlade ? 'blade' : 'compiled'; } - $file = $source->exists() ? $this->relativePath($source->path) : null; + $file = $source ? $this->relativePath($source->path) : null; $output = $node->render(); $escapedName = addslashes($componentName); @@ -51,20 +52,6 @@ public function profile(Node $node, string $componentName, ?string $strategy = n return new TextNode($wrapped); } - /** - * Determine the optimization strategy configured for a Blaze component. - */ - protected function resolveStrategy(ComponentSource $source): string - { - if (! $source->exists()) { - return 'compiled'; - } - - $memo = $source->directives->blaze('memo') ?? $this->config->shouldMemoize($source->path); - - return 'compiled'; - } - /** * Wrap a view's compiled output with timer start/stop calls. * diff --git a/src/Folder/Folder.php b/src/Folder/Folder.php index 26707e7d..67ed6a63 100644 --- a/src/Folder/Folder.php +++ b/src/Folder/Folder.php @@ -43,9 +43,9 @@ public function fold(Node $node): Node $component = $node; - $source = ComponentSource::for($this->blade->componentNameToPath($component->name)); + $source = $this->manager->components->get($component->name); - if (! $source->exists()) { + if (! $source) { return $component; } @@ -216,8 +216,11 @@ protected function slotHasDynamicAttributes(SlotNode $slot): bool */ protected function checkProblematicPatterns(ComponentSource $source): void { + // TODO: Refactor to AST + $content = file_get_contents($source->path); + // @unblaze blocks can contain dynamic content and are excluded from validation - $sourceWithoutUnblaze = preg_replace('/@unblaze.*?@endunblaze/s', '', $source->content()); + $sourceWithoutUnblaze = preg_replace('/@unblaze.*?@endunblaze/s', '', $content); $problematicPatterns = [ '@once' => 'forOnce', diff --git a/src/Memoizer/Memoizer.php b/src/Memoizer/Memoizer.php index c8aaee51..a22dda89 100644 --- a/src/Memoizer/Memoizer.php +++ b/src/Memoizer/Memoizer.php @@ -8,7 +8,6 @@ use Livewire\Blaze\Parser\Nodes\TextNode; use Livewire\Blaze\Parser\Nodes\Node; use Livewire\Blaze\Config; -use Livewire\Blaze\Support\ComponentSource; use Livewire\Blaze\Compiler\Compiler; /** @@ -77,9 +76,9 @@ protected function isMemoizable(Node $node): bool return false; } - $source = ComponentSource::for($this->blade->componentNameToPath($node->name)); + $source = $this->manager->components->get($node->name); - if (! $source->exists()) { + if (! $source) { return false; } diff --git a/src/Parser/Walker.php b/src/Parser/Walker.php index 939dc51a..9ca0cb69 100644 --- a/src/Parser/Walker.php +++ b/src/Parser/Walker.php @@ -45,4 +45,15 @@ public function iterate(array $nodes): \Generator } } } + + public function filter(array $nodes, callable $predicate): array + { + return iterator_to_array((function () use ($nodes, $predicate) { + foreach ($this->iterate($nodes) as $key => $value) { + if ($predicate($value)) { + yield $key => $value; + } + } + })()); + } } diff --git a/src/Runtime/BlazeRuntime.php b/src/Runtime/BlazeRuntime.php index 2561964b..62712231 100644 --- a/src/Runtime/BlazeRuntime.php +++ b/src/Runtime/BlazeRuntime.php @@ -12,6 +12,14 @@ use Livewire\Blaze\Support\Directives; use Livewire\Blaze\Support\Utils; use Livewire\Blaze\Debugger; +use Livewire\Blaze\Parser\Nodes\DirectiveNode; +use Livewire\Blaze\Parser\Parser; +use Livewire\Blaze\Support\AttributeParser; +use Livewire\Blaze\Parser\Tokenizer; +use Livewire\Blaze\Parser\Walker; +use Livewire\Blaze\BlazeManager; +use Livewire\Blaze\Support\ComponentRepository; +use Livewire\Blaze\Support\ComponentSource; /** * Runtime context shared with all Blaze-compiled components via $__blaze. @@ -35,6 +43,7 @@ public function __construct( public Debugger $debugger, protected BladeCompiler $compiler, protected BladeService $blade, + protected ComponentRepository $components, ) { } @@ -65,50 +74,35 @@ public function ensureRequired(string $path, string $compiledPath): void */ public function resolve(string $component): string|false { - if (isset($this->paths[$component])) { - $path = $this->paths[$component]; - } else { - $path = $this->paths[$component] = $this->blade->componentNameToPath($component); - } + $source = $this->components->get($component); - if (! $this->isBlazeComponent($path)) { + if (! $source || ! $this->isBlazeComponent($source)) { return false; } - $hash = Utils::hash($path); - $compiled = $this->getCompiledPath().'/'.$hash.'.php'; + $compiled = $this->getCompiledPath().'/'.$source->hash.'.php'; - if (! isset($this->required[$path])) { - $this->ensureRequired($path, $compiled); + if (! isset($this->required[$source->path])) { + $this->ensureRequired($source->path, $compiled); } - return $hash; + return $source->hash; } /** * Check if a component file is a Blaze component. */ - protected function isBlazeComponent(string $path): bool + protected function isBlazeComponent(ComponentSource $source): bool { - if (isset($this->blazed[$path])) { - return $this->blazed[$path]; - } - - if (! file_exists($path)) { - return $this->blazed[$path] = false; - } - - $directives = new Directives(file_get_contents($path)); - - if ($directives->blaze()) { - return $this->blazed[$path] = true; + if ($source->directives->blaze()) { + return $this->blazed[$source->path] = true; } $config = app('blaze.config'); - return $this->blazed[$path] = $config->shouldCompile($path) - || $config->shouldMemoize($path) - || $config->shouldFold($path); + return $this->blazed[$source->path] = $config->shouldCompile($source->path) + || $config->shouldMemoize($source->path) + || $config->shouldFold($source->path); } /** diff --git a/src/Support/ComponentRepository.php b/src/Support/ComponentRepository.php new file mode 100644 index 00000000..daa0fe3c --- /dev/null +++ b/src/Support/ComponentRepository.php @@ -0,0 +1,38 @@ +parser = new Parser(new Tokenizer($blade), new AttributeParser($blade)); + } + + public function get(string $name): ?ComponentSource + { + if (array_key_exists($name, $this->components)) { + return $this->components[$name]; + } + + $path = $this->blade->componentNameToPath($name); + + if (! file_exists($path)) { + return $this->components[$name] = null; + } + + $ast = $this->parser->parse(file_get_contents($path)); + + return $this->components[$name] = new ComponentSource($name, $path, $ast); + } +} \ No newline at end of file diff --git a/src/Support/ComponentSource.php b/src/Support/ComponentSource.php index 9bca2cc4..839143e7 100644 --- a/src/Support/ComponentSource.php +++ b/src/Support/ComponentSource.php @@ -2,44 +2,22 @@ namespace Livewire\Blaze\Support; -/** - * Resolves and caches a component's file path and directive metadata. - */ +use Livewire\Blaze\Parser\Walker; +use Livewire\Blaze\Parser\Nodes\DirectiveNode; + class ComponentSource { - /** @var array */ - protected static array $cache = []; - - public readonly string $path; - public readonly Directives $directives; - - public function __construct(string $path) - { - $this->path = $path; - $this->directives = new Directives($this->exists() ? $this->content() : ''); - } - - /** - * Get a cached instance for a given path, or create one. - */ - public static function for(string $path): static - { - return static::$cache[$path] ??= new static($path); - } + public string $hash; + public Directives $directives; - /** - * Check if the component file exists on disk. - */ - public function exists(): bool - { - return file_exists($this->path); - } - - /** - * Get the raw source content of the component file. - */ - public function content(): string - { - return file_get_contents($this->path); + public function __construct( + public string $name, + public string $path, + public array $ast, + ) { + $this->hash = Utils::hash($path); + $this->directives = new Directives( + (new Walker)->filter($ast, fn ($node) => $node instanceof DirectiveNode) + ); } -} +} \ No newline at end of file diff --git a/src/Support/Directives.php b/src/Support/Directives.php index 64dfc8af..e05f0ad0 100644 --- a/src/Support/Directives.php +++ b/src/Support/Directives.php @@ -2,27 +2,20 @@ namespace Livewire\Blaze\Support; +use Illuminate\Support\Arr; use Livewire\Blaze\Compiler\ArrayParser; -use Livewire\Blaze\Compiler\DirectiveCompiler; /** * Extracts and queries Blade directives from component source content. */ class Directives { - /** @var array */ - protected array $parsed; + /** @var array */ + protected array $directives; - protected string $content; - - public function __construct(string $content) + public function __construct(array $nodes) { - $this->content = $content; - $this->content = preg_replace(LaravelRegex::BLADE_COMMENT, '', $this->content); - $this->content = preg_replace(LaravelRegex::VERBATIM_BLOCK, '', $this->content); - $this->content = preg_replace(LaravelRegex::PHP_BLOCK, '', $this->content); - - $this->parsed = $this->parseKnownDirectives(); + $this->directives = Arr::mapWithKeys($nodes, fn ($node) => [$node->name => $node]); } /** @@ -30,9 +23,7 @@ public function __construct(string $content) */ public function has(string $name): bool { - $this->resolveIfNeeded($name); - - return $this->parsed[$name] !== null; + return isset($this->directives[$name]); } /** @@ -40,9 +31,7 @@ public function has(string $name): bool */ public function get(string $name): ?string { - $this->resolveIfNeeded($name); - - return $this->parsed[$name]; + return isset($this->directives[$name]) ? ($this->directives[$name]?->expression ?? '') : null; } /** @@ -100,49 +89,4 @@ public function blaze(?string $param = null): mixed return null; } - - /** - * If a directive hasn't been resolved yet, do a one-off compile - * for it and cache the result (or null if absent). - */ - protected function resolveIfNeeded(string $name): void - { - if (array_key_exists($name, $this->parsed)) { - return; - } - - $result = null; - - DirectiveCompiler::make()->directive($name, function ($expression) use (&$result) { - $result = $expression; - - return ''; - })->compile($this->content); - - $this->parsed[$name] = $result; - } - - /** - * Extract all known Blaze directives in a single DirectiveCompiler pass. - */ - protected function parseKnownDirectives(): array - { - $directives = []; - - $capture = function (string $name) use (&$directives) { - return function ($expression) use ($name, &$directives) { - $directives[$name] = $expression; - - return ''; - }; - }; - - DirectiveCompiler::make() - ->directive('blaze', $capture('blaze')) - ->directive('props', $capture('props')) - ->directive('aware', $capture('aware')) - ->compile($this->content); - - return $directives; - } } diff --git a/tests/Folder/FoldableTest.php b/tests/Folder/FoldableTest.php index c928691c..ed733563 100644 --- a/tests/Folder/FoldableTest.php +++ b/tests/Folder/FoldableTest.php @@ -6,7 +6,7 @@ use Livewire\Blaze\Folder\Foldable; use Livewire\Blaze\Parser\Attribute; use Livewire\Blaze\Parser\Parser; -use Livewire\Blaze\Support\ComponentSource; +use Livewire\Blaze\Support\ComponentRepository; beforeEach(fn () => Artisan::call('view:clear')); @@ -14,7 +14,7 @@ $input = ''; $node = app(Parser::class)->parse($input)[0]; - $foldable = new Foldable($node, new ComponentSource(fixture_path('views/components/foldable/input.blade.php')), app(BladeRenderer::class), app(BladeService::class)); + $foldable = new Foldable($node, app(ComponentRepository::class)->get('foldable.input'), app(BladeRenderer::class), app(BladeService::class)); expect($foldable->fold())->toEqualCollapsingWhitespace( '' @@ -38,7 +38,7 @@ ; $node = app(Parser::class)->parse($input)[0]; - $foldable = new Foldable($node, new ComponentSource(fixture_path('views/components/foldable/card.blade.php')), app(BladeRenderer::class), app(BladeService::class)); + $foldable = new Foldable($node, app(ComponentRepository::class)->get('foldable.card'), app(BladeRenderer::class), app(BladeService::class)); expect($foldable->fold())->toEqualCollapsingWhitespace(<<<'HTML'
@@ -56,7 +56,7 @@ $input = ''; $node = app(Parser::class)->parse($input)[0]; - $foldable = new Foldable($node, new ComponentSource(fixture_path('views/components/foldable/input.blade.php')), app(BladeRenderer::class), app(BladeService::class)); + $foldable = new Foldable($node, app(ComponentRepository::class)->get('foldable.input'), app(BladeRenderer::class), app(BladeService::class)); expect($foldable->fold())->toEqualCollapsingWhitespace( '' @@ -67,7 +67,7 @@ $input = ''; $node = app(Parser::class)->parse($input)[0]; - $foldable = new Foldable($node, new ComponentSource(fixture_path('views/components/foldable/input.blade.php')), app(BladeRenderer::class), app(BladeService::class)); + $foldable = new Foldable($node, app(ComponentRepository::class)->get('foldable.input'), app(BladeRenderer::class), app(BladeService::class)); expect($foldable->fold())->toEqualCollapsingWhitespace( '' @@ -78,7 +78,7 @@ $input = ''; $node = app(Parser::class)->parse($input)[0]; - $foldable = new Foldable($node, new ComponentSource(fixture_path('views/components/foldable/input-aware.blade.php')), app(BladeRenderer::class), app(BladeService::class)); + $foldable = new Foldable($node, app(ComponentRepository::class)->get('foldable.input-aware'), app(BladeRenderer::class), app(BladeService::class)); $node->setParentsAttributes([ 'type' => new Attribute( @@ -109,7 +109,7 @@ ), ]); - $foldable = new Foldable($node, new ComponentSource(fixture_path('views/components/foldable/input-aware.blade.php')), app(BladeRenderer::class), app(BladeService::class)); + $foldable = new Foldable($node, app(ComponentRepository::class)->get('foldable.input-aware'), app(BladeRenderer::class), app(BladeService::class)); expect($foldable->fold())->toEqualCollapsingWhitespace( '' @@ -120,7 +120,7 @@ $input = ''; $node = app(Parser::class)->parse($input)[0]; - $foldable = new Foldable($node, new ComponentSource(fixture_path('views/components/foldable/input.blade.php')), app(BladeRenderer::class), app(BladeService::class)); + $foldable = new Foldable($node, app(ComponentRepository::class)->get('foldable.input'), app(BladeRenderer::class), app(BladeService::class)); expect($foldable->fold())->toEqualCollapsingWhitespace( sprintf('', join('', [ @@ -134,7 +134,7 @@ test('folds dynamic attributes reused under a different key', function () { $input = ''; $node = app(Parser::class)->parse($input)[0]; - $foldable = new Foldable($node, new ComponentSource(fixture_path('views/components/foldable/button.blade.php')), app(BladeRenderer::class), app(BladeService::class)); + $foldable = new Foldable($node, app(ComponentRepository::class)->get('foldable.button'), app(BladeRenderer::class), app(BladeService::class)); expect($foldable->fold())->toEqualCollapsingWhitespace( '' ); @@ -146,7 +146,7 @@ $node = app(Parser::class)->parse($input)[0]; $node->hasAwareDescendants = true; - $foldable = new Foldable($node, new ComponentSource(fixture_path('views/components/foldable/wrapper.blade.php')), app(BladeRenderer::class), app(BladeService::class)); + $foldable = new Foldable($node, app(ComponentRepository::class)->get('foldable.wrapper'), app(BladeRenderer::class), app(BladeService::class)); expect($foldable->fold())->toEqualCollapsingWhitespace(join('', [ 'pushData([\'name\' => \'John\']); $__env->pushConsumableComponentData([\'name\' => \'John\']); ?>', @@ -161,7 +161,7 @@ $node = app(Parser::class)->parse($input)[0]; $node->hasAwareDescendants = true; - $foldable = new Foldable($node, new ComponentSource(fixture_path('views/components/foldable/wrapper.blade.php')), app(BladeRenderer::class), app(BladeService::class)); + $foldable = new Foldable($node, app(ComponentRepository::class)->get('foldable.wrapper'), app(BladeRenderer::class), app(BladeService::class)); expect($foldable->fold())->toEqualCollapsingWhitespace(join('', [ 'pushData([\'name\' => $name]); $__env->pushConsumableComponentData([\'name\' => $name]); ?>', @@ -176,7 +176,7 @@ $node = app(Parser::class)->parse($input)[0]; $node->hasAwareDescendants = true; - $foldable = new Foldable($node, new ComponentSource(fixture_path('views/components/foldable/wrapper.blade.php')), app(BladeRenderer::class), app(BladeService::class)); + $foldable = new Foldable($node, app(ComponentRepository::class)->get('foldable.wrapper'), app(BladeRenderer::class), app(BladeService::class)); expect($foldable->fold())->toEqualCollapsingWhitespace(join('', [ 'pushData([\'name\' => \'Mr. \'.e($name)]); $__env->pushConsumableComponentData([\'name\' => \'Mr. \'.e($name)]); ?>', diff --git a/tests/Folder/UnblazeTest.php b/tests/Folder/UnblazeTest.php index 508548e4..793eaad2 100644 --- a/tests/Folder/UnblazeTest.php +++ b/tests/Folder/UnblazeTest.php @@ -3,14 +3,14 @@ use Livewire\Blaze\BladeRenderer; use Livewire\Blaze\BladeService; use Livewire\Blaze\Folder\Foldable; -use Livewire\Blaze\Support\ComponentSource; use Livewire\Blaze\Parser\Parser; +use Livewire\Blaze\Support\ComponentRepository; test('compiles unblaze blocks', function () { $input = ''; $node = app(Parser::class)->parse($input)[0]; - $foldable = new Foldable($node, new ComponentSource(fixture_path('views/components/foldable/input-unblaze.blade.php')), app(BladeRenderer::class), app(BladeService::class)); + $foldable = new Foldable($node, app(ComponentRepository::class)->get('foldable.input-unblaze'), app(BladeRenderer::class), app(BladeService::class)); expect($foldable->fold())->toEqualCollapsingWhitespace( sprintf('', join('', [ @@ -26,7 +26,7 @@ $input = ''; $node = app(Parser::class)->parse($input)[0]; - $foldable = new Foldable($node, new ComponentSource(fixture_path('views/components/foldable/nested-input-unblaze.blade.php')), app(BladeRenderer::class), app(BladeService::class)); + $foldable = new Foldable($node, app(ComponentRepository::class)->get('foldable.nested-input-unblaze'), app(BladeRenderer::class), app(BladeService::class)); expect($foldable->fold())->toEqualCollapsingWhitespace( sprintf('
', join('', [ @@ -42,7 +42,7 @@ $input = ''; $node = app(Parser::class)->parse($input)[0]; - $foldable = new Foldable($node, new ComponentSource(fixture_path('views/components/foldable/input-unblaze.blade.php')), app(BladeRenderer::class), app(BladeService::class)); + $foldable = new Foldable($node, app(ComponentRepository::class)->get('foldable.input-unblaze'), app(BladeRenderer::class), app(BladeService::class)); expect($foldable->fold())->toEqualCollapsingWhitespace( sprintf('', join('', [ diff --git a/tests/Runtime/BlazeRuntimeTest.php b/tests/Runtime/BlazeRuntimeTest.php index 0fb56efd..df954d22 100644 --- a/tests/Runtime/BlazeRuntimeTest.php +++ b/tests/Runtime/BlazeRuntimeTest.php @@ -1,6 +1,19 @@ get('input'); + + expect(app(BlazeRuntime::class)->resolve('input'))->toBe($source->hash); + + expect(function_exists('_' . $source->hash))->toBeTrue(); +}); + +it('resolve returns false when component doesnt exist', function () { + expect(app(BlazeRuntime::class)->resolve('nonexistent'))->toBeFalse(); +}); it('processPassthroughContent', function ($input, $results) { $input = str_replace('[UNBLAZE]', '[STARTCOMPILEDUNBLAZE:XXX][ENDCOMPILEDUNBLAZE:XXX]', $input); diff --git a/tests/Support/DirectivesTest.php b/tests/Support/DirectivesTest.php index 0ab83e3a..e8da7dce 100644 --- a/tests/Support/DirectivesTest.php +++ b/tests/Support/DirectivesTest.php @@ -1,39 +1,41 @@ null, \'value\']))'); + $directives = new Directives( + app(Parser::class)->parse('@aware([\'name\' => null, \'value\'])') + ); expect($directives->array('aware'))->toBe(['name' => null, 'value']); }); test('parses props', function () { - $directives = new Directives('@props([\'name\' => null, \'value\']))'); + $directives = new Directives( + app(Parser::class)->parse('@props([\'name\' => null, \'value\'])') + ); expect($directives->props())->toBe(['name', 'value']); }); test('parses blaze directive', function () { - $directives = new Directives('@blaze'); + $directives = new Directives( + app(Parser::class)->parse('@blaze') + ); expect($directives->has('blaze'))->toBeTrue(); expect($directives->get('blaze'))->toBe(''); }); test('parses blaze directive with params', function () { - $directives = new Directives('@blaze(fold: true, safe: [\'name\'])'); + $directives = new Directives( + app(Parser::class)->parse('@blaze(fold: true, safe: [\'name\'])') + ); expect($directives->blaze())->toBeTrue(); expect($directives->blaze('fold'))->toBeTrue(); expect($directives->blaze('safe'))->toBe(['name']); expect($directives->blaze('memo'))->toBeNull(); }); - -test('ignores directives in php blocks and comments', function ($input) { - expect((new Directives($input))->has('aware'))->toBeFalse(); -})->with([ - ['@php // @aware @endphp'], - [''], - ['{{-- @aware --}}'], -]); From 584e9abbf8156e449302501477fd346f1d82771e Mon Sep 17 00:00:00 2001 From: Filip Ganyicz Date: Thu, 6 Aug 2026 17:02:36 +0200 Subject: [PATCH 10/56] Fix visibility --- src/BlazeManager.php | 8 ++++---- src/Compiler/Compiler.php | 4 +++- src/Compiler/Profiler.php | 4 +++- src/Folder/Folder.php | 4 +++- src/Memoizer/Memoizer.php | 4 +++- 5 files changed, 16 insertions(+), 8 deletions(-) diff --git a/src/BlazeManager.php b/src/BlazeManager.php index c0232369..07322c52 100644 --- a/src/BlazeManager.php +++ b/src/BlazeManager.php @@ -52,11 +52,11 @@ public function __construct( $this->renderer = new BladeRenderer($bladeCompiler, app('view'), $this->runtime, $this); $this->parser = new Parser(new Tokenizer($this->blade), 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); - $this->memoizer = new Memoizer($config, $this->compiler, $this->blade, $this); + $this->compiler = new Compiler($config, $this->blade, $this, $this->components); + $this->folder = new Folder($config, $this->blade, $this->renderer, $this, $this->components); + $this->memoizer = new Memoizer($config, $this->compiler, $this->blade, $this, $this->components); $this->wrapper = new Wrapper($this->blade, $this); - $this->instrumenter = new Profiler($config, $this->blade, $this); + $this->instrumenter = new Profiler($config, $this->blade, $this, $this->components); Event::listen(ComponentFolded::class, function (ComponentFolded $event) { $this->foldedEvents[] = $event; diff --git a/src/Compiler/Compiler.php b/src/Compiler/Compiler.php index b70e27e5..e4a1159b 100644 --- a/src/Compiler/Compiler.php +++ b/src/Compiler/Compiler.php @@ -9,6 +9,7 @@ use Livewire\Blaze\Parser\Nodes\TextNode; use Livewire\Blaze\Parser\Nodes\Node; use Livewire\Blaze\Config; +use Livewire\Blaze\Support\ComponentRepository; use Livewire\Blaze\Support\ComponentSource; use Livewire\Blaze\Support\Utils; @@ -23,6 +24,7 @@ public function __construct( protected Config $config, protected BladeService $blade, protected BlazeManager $manager, + protected ComponentRepository $components, ) { $this->slotCompiler = new SlotCompiler($manager, $blade); } @@ -40,7 +42,7 @@ public function compile(Node $node): Node return new TextNode($this->compileDelegateComponentTag($node)); } - $source = $this->manager->components->get($node->name); + $source = $this->components->get($node->name); if (! $source) { return $node; diff --git a/src/Compiler/Profiler.php b/src/Compiler/Profiler.php index be073929..46820aaa 100644 --- a/src/Compiler/Profiler.php +++ b/src/Compiler/Profiler.php @@ -8,6 +8,7 @@ use Livewire\Blaze\Parser\Nodes\ComponentNode; use Livewire\Blaze\Parser\Nodes\Node; use Livewire\Blaze\Parser\Nodes\TextNode; +use Livewire\Blaze\Support\ComponentRepository; /** * Wraps every component's compiled output with profiler timer calls. @@ -24,6 +25,7 @@ public function __construct( protected Config $config, protected BladeService $blade, protected BlazeManager $manager, + protected ComponentRepository $components, ) { } @@ -32,7 +34,7 @@ public function __construct( */ public function profile(Node $node, string $componentName, ?string $strategy = null): Node { - $source = $this->manager->components->get($componentName); + $source = $this->components->get($componentName); if ($strategy === null) { $isBlade = $node instanceof ComponentNode; diff --git a/src/Folder/Folder.php b/src/Folder/Folder.php index 67ed6a63..67091959 100644 --- a/src/Folder/Folder.php +++ b/src/Folder/Folder.php @@ -18,6 +18,7 @@ use Livewire\Blaze\Parser\Nodes\DirectiveNode; use Livewire\Blaze\Support\DirectiveStack; use Throwable; +use Livewire\Blaze\Support\ComponentRepository; /** * Determines whether a component should be folded and orchestrates the folding process. @@ -29,6 +30,7 @@ public function __construct( protected BladeService $blade, protected BladeRenderer $renderer, protected BlazeManager $manager, + protected ComponentRepository $components, ) { } @@ -43,7 +45,7 @@ public function fold(Node $node): Node $component = $node; - $source = $this->manager->components->get($component->name); + $source = $this->components->get($component->name); if (! $source) { return $component; diff --git a/src/Memoizer/Memoizer.php b/src/Memoizer/Memoizer.php index a22dda89..8dd3be79 100644 --- a/src/Memoizer/Memoizer.php +++ b/src/Memoizer/Memoizer.php @@ -9,6 +9,7 @@ use Livewire\Blaze\Parser\Nodes\Node; use Livewire\Blaze\Config; use Livewire\Blaze\Compiler\Compiler; +use Livewire\Blaze\Support\ComponentRepository; /** * Wraps compiled component output with runtime memoization logic. @@ -20,6 +21,7 @@ public function __construct( protected Compiler $compiler, protected BladeService $blade, protected BlazeManager $manager, + protected ComponentRepository $components, ) { } @@ -76,7 +78,7 @@ protected function isMemoizable(Node $node): bool return false; } - $source = $this->manager->components->get($node->name); + $source = $this->components->get($node->name); if (! $source) { return false; From 710556689473fac650bbb2032bae2b4e1af0d250 Mon Sep 17 00:00:00 2001 From: Filip Ganyicz Date: Thu, 6 Aug 2026 19:22:06 +0200 Subject: [PATCH 11/56] Cache parsed templates --- src/BlazeManager.php | 55 +++++++++++------------- src/Compiler/Compiler.php | 14 +++--- src/Folder/Foldable.php | 2 +- src/Folder/Folder.php | 38 ++++++++--------- src/Memoizer/Memoizer.php | 2 +- src/Parser/Parser.php | 16 ++++++- src/Parser/Template.php | 21 +++++++++ src/Runtime/BlazeRuntime.php | 26 ++++++------ src/Support/ComponentRepository.php | 2 +- src/Support/ComponentSource.php | 9 +--- tests/Compiler/CompilerTest.php | 8 ++-- tests/Compiler/WrapperTest.php | 18 ++++---- tests/Folder/FoldableTest.php | 22 +++++----- tests/Folder/FolderTest.php | 66 ++++++++++++++--------------- tests/Folder/UnblazeTest.php | 6 +-- tests/Memoizer/MemoizerTest.php | 10 ++--- tests/Parser/ParserTest.php | 20 ++++----- tests/Support/DirectivesTest.php | 8 ++-- 18 files changed, 181 insertions(+), 162 deletions(-) create mode 100644 src/Parser/Template.php diff --git a/src/BlazeManager.php b/src/BlazeManager.php index 07322c52..b0703e15 100644 --- a/src/BlazeManager.php +++ b/src/BlazeManager.php @@ -5,6 +5,7 @@ use Illuminate\Support\Facades\Event; use Illuminate\View\Compilers\BladeCompiler; use Illuminate\View\Engines\CompilerEngine; +use Illuminate\View\View; use Livewire\Blaze\Compiler\Wrapper; use Livewire\Blaze\Compiler\Compiler; use Livewire\Blaze\Compiler\Profiler; @@ -66,12 +67,14 @@ public function __construct( /** * Compile a Blade template through the full Blaze pipeline. */ - public function compile(string $template, ?string $path = null): string + public function compile(string $source, ?string $path = null): string { $dataStack = []; + $template = $this->parser->parse($source, $path); + $ast = $this->walker->walk( - nodes: $this->parser->parse($template), + nodes: $template->nodes, preCallback: function ($node) use (&$dataStack) { if ($node instanceof ComponentNode && $node->children) { $dataStack[] = $node->attributes; @@ -111,12 +114,10 @@ public function compile(string $template, ?string $path = null): string $output = $this->render($ast); - $directives = new Directives($this->walker->filter($ast, fn ($n) => $n instanceof DirectiveNode)); - - if ($path && ($directives->blaze() || $this->config->shouldCompile($path))) { + if ($path && ($template->directives->blaze() || $this->config->shouldCompile($path))) { $output = $this->render($this->wrapper->wrap($ast, $path)); } elseif ($this->isDebugging() && ! $this->isFolding() && $path) { - $output = $this->instrumenter->profileView($output, $path, $template); + $output = $this->instrumenter->profileView($output, $path, $source); } return $output; @@ -125,10 +126,12 @@ public function compile(string $template, ?string $path = null): string /** * Compile a template within an @unblaze block (no folding, no wrapping). */ - public function compileForUnblaze(string $template): string + public function compileForUnblaze(string $source): string { + $template = $this->parser->parse($source); + $ast = $this->walker->walk( - nodes: $this->parser->parse($template), + nodes: $template->nodes, preCallback: fn ($node) => $node, postCallback: function ($node) { $wasComponent = $node instanceof ComponentNode; @@ -145,13 +148,7 @@ public function compileForUnblaze(string $template): string }, ); - $output = $this->render($ast); - - // We should not restore raw blocks here. Doing so would preemptively - // flush all raw blocks stored in the original template and they - // wouldn't be restored in the parent compile() method. - - return $output; + return $this->render($ast); } /** @@ -161,12 +158,12 @@ public function compileForUnblaze(string $template): string * calls, but does NOT fold, memoize, or compile — Blade handles that. * Also injects view-level timers for non-wrapped views. */ - public function compileForDebug(string $template, ?string $path = null): string + public function compileForDebug(string $source, ?string $path = null): string { - $source = $template; + $template = $this->parser->parse($source, $path); $ast = $this->walker->walk( - nodes: $this->parser->parse($template), + nodes: $template->nodes, preCallback: fn ($node) => $node, postCallback: function ($node) { if (! ($node instanceof ComponentNode)) { @@ -190,12 +187,12 @@ public function compileForDebug(string $template, ?string $path = null): string * Compile for folding context - only tag compiler and component compiler. * No folding or memoization to avoid infinite recursion. */ - public function compileForFolding(string $template, ?string $path = null): string + public function compileForFolding(string $source, ?string $path = null): string { - $source = $template; + $template = $this->parser->parse($source, $path); $ast = $this->walker->walk( - nodes: $this->parser->parse($template), + nodes: $template->nodes, preCallback: fn ($node) => $node, postCallback: function ($node) { return $this->compiler->compile($node); @@ -208,15 +205,11 @@ public function compileForFolding(string $template, ?string $path = null): strin return $output; } - $directives = new Directives( - (new Walker)->filter($ast, fn ($node) => $node instanceof DirectiveNode) - ); - $shouldWrap = $this->config->shouldFold($path) || $this->config->shouldMemoize($path) || $this->config->shouldCompile($path); - if ($directives->blaze() || $shouldWrap) { + if ($template->directives->blaze() || $shouldWrap) { $output = $this->render($this->wrapper->wrap($ast, $path)); } @@ -238,11 +231,11 @@ public function flushFoldedEvents() /** * Run a compilation callback and prepend front matter from any folded components. */ - public function collectAndAppendFrontMatter($template, $callback) + public function collectAndAppendFrontMatter(string $source, callable $callback) { $this->flushFoldedEvents(); - $output = $callback($template); + $output = $callback($source); $frontmatter = (new FrontMatter)->compileFromEvents( $this->flushFoldedEvents() @@ -254,7 +247,7 @@ public function collectAndAppendFrontMatter($template, $callback) /** * Check if a view's compiled output contains stale folded component references. */ - public function viewContainsExpiredFrontMatter($view): bool + public function viewContainsExpiredFrontMatter(View $view): bool { $engine = $view->getEngine(); $path = $view->getPath(); @@ -385,13 +378,13 @@ protected function hasAwareDescendant(ComponentNode|SlotNode $node): bool { foreach ($node->children as $child) { if ($child instanceof ComponentNode) { - $source = $this->components->get($child->name); + $component = $this->components->get($child->name); if (str_ends_with($child->name, 'delegate-component')) { return true; } - if ($source->directives->has('aware')) { + if ($component->template->directives->has('aware')) { return true; } diff --git a/src/Compiler/Compiler.php b/src/Compiler/Compiler.php index e4a1159b..1f92b042 100644 --- a/src/Compiler/Compiler.php +++ b/src/Compiler/Compiler.php @@ -42,13 +42,13 @@ public function compile(Node $node): Node return new TextNode($this->compileDelegateComponentTag($node)); } - $source = $this->components->get($node->name); + $component = $this->components->get($node->name); - if (! $source) { + if (! $component) { return $node; } - if (! $this->shouldCompile($source)) { + if (! $this->shouldCompile($component)) { return $node; } @@ -56,7 +56,7 @@ public function compile(Node $node): Node return $node; } - return new TextNode($this->compileComponentTag($node, $source)); + return new TextNode($this->compileComponentTag($node, $component)); } /** @@ -64,8 +64,8 @@ public function compile(Node $node): Node */ protected function shouldCompile(ComponentSource $source): bool { - if ($source->directives->blaze()) { - return $source->directives->blaze('compile') ?? true; + if ($source->template->directives->blaze()) { + return $source->template->directives->blaze('compile') ?? true; } return $this->config->shouldCompile($source->path) @@ -94,7 +94,7 @@ protected function hasDynamicSlotNames(ComponentNode $node): bool */ protected function compileComponentTag(ComponentNode $node, ComponentSource $source): string { - $hash = Utils::hash($source->path); + $hash = $source->hash; $functionName = ($this->manager->isFolding() ? '__' : '_') . $hash; [$attributesArrayString, $boundKeysArrayString, $originalKeysArrayString] = $this->compileAttributes($node); diff --git a/src/Folder/Foldable.php b/src/Folder/Foldable.php index ec03b8db..5d51c1f3 100644 --- a/src/Folder/Foldable.php +++ b/src/Folder/Foldable.php @@ -145,7 +145,7 @@ protected function setupSlots(): void */ protected function mergeAwareProps(): void { - $aware = $this->source->directives->array('aware') ?? []; + $aware = $this->source->template->directives->array('aware') ?? []; foreach ($aware as $prop => $default) { if (is_int($prop)) { diff --git a/src/Folder/Folder.php b/src/Folder/Folder.php index 67091959..be312ff6 100644 --- a/src/Folder/Folder.php +++ b/src/Folder/Folder.php @@ -43,33 +43,31 @@ public function fold(Node $node): Node return $node; } - $component = $node; + $component = $this->components->get($node->name); - $source = $this->components->get($component->name); - - if (! $source) { - return $component; + if (! $component) { + return $node; } - if (! $this->shouldFold($source)) { - return $component; + if (! $this->shouldFold($component)) { + return $node; } - if (! $this->isSafeToFold($source, $component)) { - return $component; + if (! $this->isSafeToFold($component, $node)) { + return $node; } - $this->checkProblematicPatterns($source); + $this->checkProblematicPatterns($component); try { - $foldable = new Foldable($node, $source, $this->renderer, $this->blade); + $foldable = new Foldable($node, $component, $this->renderer, $this->blade); $html = $foldable->fold(); Event::dispatch(new ComponentFolded( - name: $component->name, - path: $source->path, - filemtime: filemtime($source->path), + name: $node->name, + path: $component->path, + filemtime: filemtime($component->path), )); return new TextNode('' . $html . ''); @@ -87,7 +85,7 @@ public function fold(Node $node): Node */ protected function shouldFold(ComponentSource $source): bool { - $shouldFold = $source->directives->blaze('fold'); + $shouldFold = $source->template->directives->blaze('fold'); if ($this->config && is_null($shouldFold)) { return $this->config->shouldFold($source->path); @@ -107,7 +105,7 @@ protected function isSafeToFold(ComponentSource $source, ComponentNode $node): b $dynamicAttributes = array_filter($node->attributes, fn ($attribute) => ! $attribute->isStaticValue()); - foreach ($source->directives->aware() as $prop) { + foreach ($source->template->directives->aware() as $prop) { if (! isset($node->attributes[$prop]) && isset($node->parentsAttributes[$prop]) && ! $node->parentsAttributes[$prop]->isStaticValue() @@ -128,11 +126,11 @@ protected function isSafeToFold(ComponentSource $source, ComponentNode $node): b } } - $props = $source->directives->props(); - $aware = $source->directives->aware(); + $props = $source->template->directives->props(); + $aware = $source->template->directives->aware(); - $safe = Arr::wrap($source->directives->blaze('safe')); - $unsafe = Arr::wrap($source->directives->blaze('unsafe')); + $safe = Arr::wrap($source->template->directives->blaze('safe')); + $unsafe = Arr::wrap($source->template->directives->blaze('unsafe')); if (in_array('*', $safe)) { return true; diff --git a/src/Memoizer/Memoizer.php b/src/Memoizer/Memoizer.php index 8dd3be79..ad510e77 100644 --- a/src/Memoizer/Memoizer.php +++ b/src/Memoizer/Memoizer.php @@ -84,7 +84,7 @@ protected function isMemoizable(Node $node): bool return false; } - if (! is_null($memo = $source->directives->blaze('memo'))) { + if (! is_null($memo = $source->template->directives->blaze('memo'))) { return $memo; } diff --git a/src/Parser/Parser.php b/src/Parser/Parser.php index e1725686..1692f96e 100644 --- a/src/Parser/Parser.php +++ b/src/Parser/Parser.php @@ -22,6 +22,8 @@ */ class Parser { + public array $templates = []; + public function __construct( protected Tokenizer $tokenizer, protected AttributeParser $attributes, @@ -31,8 +33,12 @@ public function __construct( /** * Parse tokens into an AST. */ - public function parse(string $content): array + public function parse(string $content, ?string $path = null): Template { + if ($path && isset($this->templates[$path])) { + return $this->templates[$path]; + } + $stack = new ParseStack; $tokens = $this->tokenizer->tokenize($content); @@ -49,7 +55,13 @@ public function parse(string $content): array }; } - return $stack->getAst(); + $template = new Template($stack->getAst()); + + if ($path) { + $this->templates[$path] = $template; + } + + return $template; } /** diff --git a/src/Parser/Template.php b/src/Parser/Template.php new file mode 100644 index 00000000..60e58796 --- /dev/null +++ b/src/Parser/Template.php @@ -0,0 +1,21 @@ +directives = new Directives( + (new Walker)->filter($nodes, function (Node $node) { + return $node->isDirective(['blaze', 'aware', 'props']); + }) + ); + } +} \ No newline at end of file diff --git a/src/Runtime/BlazeRuntime.php b/src/Runtime/BlazeRuntime.php index 62712231..d6d37094 100644 --- a/src/Runtime/BlazeRuntime.php +++ b/src/Runtime/BlazeRuntime.php @@ -72,37 +72,37 @@ public function ensureRequired(string $path, string $compiledPath): void * (no @blaze directive and not configured for compilation), so the * caller can fall back to standard Blade rendering. */ - public function resolve(string $component): string|false + public function resolve(string $name): string|false { - $source = $this->components->get($component); + $component = $this->components->get($name); - if (! $source || ! $this->isBlazeComponent($source)) { + if (! $component || ! $this->isBlazeComponent($component)) { return false; } - $compiled = $this->getCompiledPath().'/'.$source->hash.'.php'; + $compiled = $this->getCompiledPath().'/'.$component->hash.'.php'; - if (! isset($this->required[$source->path])) { - $this->ensureRequired($source->path, $compiled); + if (! isset($this->required[$component->path])) { + $this->ensureRequired($component->path, $compiled); } - return $source->hash; + return $component->hash; } /** * Check if a component file is a Blaze component. */ - protected function isBlazeComponent(ComponentSource $source): bool + protected function isBlazeComponent(ComponentSource $component): bool { - if ($source->directives->blaze()) { - return $this->blazed[$source->path] = true; + if ($component->template->directives->blaze()) { + return $this->blazed[$component->path] = true; } $config = app('blaze.config'); - return $this->blazed[$source->path] = $config->shouldCompile($source->path) - || $config->shouldMemoize($source->path) - || $config->shouldFold($source->path); + return $this->blazed[$component->path] = $config->shouldCompile($component->path) + || $config->shouldMemoize($component->path) + || $config->shouldFold($component->path); } /** diff --git a/src/Support/ComponentRepository.php b/src/Support/ComponentRepository.php index daa0fe3c..2bf6d80b 100644 --- a/src/Support/ComponentRepository.php +++ b/src/Support/ComponentRepository.php @@ -31,7 +31,7 @@ public function get(string $name): ?ComponentSource return $this->components[$name] = null; } - $ast = $this->parser->parse(file_get_contents($path)); + $ast = $this->parser->parse(file_get_contents($path), $path); return $this->components[$name] = new ComponentSource($name, $path, $ast); } diff --git a/src/Support/ComponentSource.php b/src/Support/ComponentSource.php index 839143e7..517db9fd 100644 --- a/src/Support/ComponentSource.php +++ b/src/Support/ComponentSource.php @@ -2,22 +2,17 @@ namespace Livewire\Blaze\Support; -use Livewire\Blaze\Parser\Walker; -use Livewire\Blaze\Parser\Nodes\DirectiveNode; +use Livewire\Blaze\Parser\Template; class ComponentSource { public string $hash; - public Directives $directives; public function __construct( public string $name, public string $path, - public array $ast, + public Template $template, ) { $this->hash = Utils::hash($path); - $this->directives = new Directives( - (new Walker)->filter($ast, fn ($node) => $node instanceof DirectiveNode) - ); } } \ No newline at end of file diff --git a/tests/Compiler/CompilerTest.php b/tests/Compiler/CompilerTest.php index 7860abf4..cb82f489 100644 --- a/tests/Compiler/CompilerTest.php +++ b/tests/Compiler/CompilerTest.php @@ -9,7 +9,7 @@ test('compiles self-closing components', function () { $input = ''; - $node = app(Parser::class)->parse($input)[0]; + $node = app(Parser::class)->parse($input)->nodes[0]; $compiled = app(Compiler::class)->compile($node); $path = fixture_path('views/components/input.blade.php'); @@ -37,7 +37,7 @@ BLADE ; - $node = app(Parser::class)->parse($input)[0]; + $node = app(Parser::class)->parse($input)->nodes[0]; $compiled = app(Compiler::class)->compile($node); $path = fixture_path('views/components/card.blade.php'); @@ -66,7 +66,7 @@ test('compiles delegate components', function () { $input = ''; - $node = app(Parser::class)->parse($input)[0]; + $node = app(Parser::class)->parse($input)->nodes[0]; $compiled = app(Compiler::class)->compile($node); expect($compiled->render())->toEqualCollapsingWhitespace(join('', [ @@ -88,7 +88,7 @@ app(Config::class)->add(fixture_path('views/components')); - $node = app(Parser::class)->parse($input)[0]; + $node = app(Parser::class)->parse($input)->nodes[0]; $compiled = app(Compiler::class)->compile($node); expect($compiled)->toBeInstanceOf(ComponentNode::class); diff --git a/tests/Compiler/WrapperTest.php b/tests/Compiler/WrapperTest.php index dec35814..d0bdafff 100644 --- a/tests/Compiler/WrapperTest.php +++ b/tests/Compiler/WrapperTest.php @@ -11,7 +11,7 @@ $source = file_get_contents($path); $hash = Utils::hash($path); - $ast = app(Parser::class)->parse($source); + $ast = app(Parser::class)->parse($source)->nodes; $wrapped = join('', array_map(fn ($node) => $node->render(), app(Wrapper::class)->wrap($ast, $path))); expect($wrapped)->toEqualCollapsingWhitespace(join('', [ @@ -38,7 +38,7 @@ $source = file_get_contents($path); $hash = Utils::hash($path); - $ast = app(Parser::class)->parse($source); + $ast = app(Parser::class)->parse($source)->nodes; $wrapped = join('', array_map(fn ($node) => $node->render(), app(Wrapper::class)->wrap($ast, $path))); expect($wrapped)->toEqualCollapsingWhitespace(join('', [ @@ -64,14 +64,14 @@ }); test('extracts props when props are not defined', function () { - $ast = app(Parser::class)->parse('
'); + $ast = app(Parser::class)->parse('
')->nodes; $wrapped = join('', array_map(fn ($node) => $node->render(), app(Wrapper::class)->wrap($ast, ''))); expect($wrapped)->toContain('extract($__data, EXTR_SKIP);'); }); test('wraps in self invoking closure', function ($source) { - $ast = app(Parser::class)->parse($source); + $ast = app(Parser::class)->parse($source)->nodes; $wrapped = join('', array_map(fn ($node) => $node->render(), app(Wrapper::class)->wrap($ast, ''))); expect($wrapped)->toContain( @@ -86,7 +86,7 @@ ]); test('injects variables', function ($source, $expected) { - $ast = app(Parser::class)->parse($source); + $ast = app(Parser::class)->parse($source)->nodes; $wrapped = join('', array_map(fn ($node) => $node->render(), app(Wrapper::class)->wrap($ast, ''))); expect($wrapped)->toContain($expected); @@ -103,14 +103,14 @@ test('injects echo handler', function () { Blade::stringable((new class {})::class, fn () => 'dummy'); - $ast = app(Parser::class)->parse('{{ $a }}'); + $ast = app(Parser::class)->parse('{{ $a}}')->nodes; $wrapped = join('', array_map(fn ($node) => $node->render(), app(Wrapper::class)->wrap($ast, ''))); expect($wrapped)->toContain('$__bladeCompiler = app(\'blade.compiler\');'); }); test('hoists use statements to top of output', function ($statement) { - $ast = app(Parser::class)->parse($statement); + $ast = app(Parser::class)->parse($statement)->nodes; $wrapped = join('', array_map(fn ($node) => $node->render(), app(Wrapper::class)->wrap($ast, ''))); expect($wrapped)->toStartWith("parse($input); + $ast = app(Parser::class)->parse($input)->nodes; $wrapped = join('', array_map(fn ($node) => $node->render(), app(Wrapper::class)->wrap($ast, ''))); expect($wrapped)->toContain($input); @@ -130,7 +130,7 @@ test('preserves verbatim directives', function () { $input = '@verbatim /* uncompiled */ @endverbatim'; - $ast = app(Parser::class)->parse($input); + $ast = app(Parser::class)->parse($input)->nodes; $wrapped = join('', array_map(fn ($node) => $node->render(), app(Wrapper::class)->wrap($ast, ''))); expect($wrapped)->toContain($input); diff --git a/tests/Folder/FoldableTest.php b/tests/Folder/FoldableTest.php index ed733563..80b861ec 100644 --- a/tests/Folder/FoldableTest.php +++ b/tests/Folder/FoldableTest.php @@ -13,7 +13,7 @@ test('folds dynamic attributes', function () { $input = ''; - $node = app(Parser::class)->parse($input)[0]; + $node = app(Parser::class)->parse($input)->nodes[0]; $foldable = new Foldable($node, app(ComponentRepository::class)->get('foldable.input'), app(BladeRenderer::class), app(BladeService::class)); expect($foldable->fold())->toEqualCollapsingWhitespace( @@ -37,7 +37,7 @@ BLADE ; - $node = app(Parser::class)->parse($input)[0]; + $node = app(Parser::class)->parse($input)->nodes[0]; $foldable = new Foldable($node, app(ComponentRepository::class)->get('foldable.card'), app(BladeRenderer::class), app(BladeService::class)); expect($foldable->fold())->toEqualCollapsingWhitespace(<<<'HTML' @@ -55,7 +55,7 @@ test('preserves dynamic attributes with static false', function () { $input = ''; - $node = app(Parser::class)->parse($input)[0]; + $node = app(Parser::class)->parse($input)->nodes[0]; $foldable = new Foldable($node, app(ComponentRepository::class)->get('foldable.input'), app(BladeRenderer::class), app(BladeService::class)); expect($foldable->fold())->toEqualCollapsingWhitespace( @@ -66,7 +66,7 @@ test('preserves dynamic attributes with static null', function () { $input = ''; - $node = app(Parser::class)->parse($input)[0]; + $node = app(Parser::class)->parse($input)->nodes[0]; $foldable = new Foldable($node, app(ComponentRepository::class)->get('foldable.input'), app(BladeRenderer::class), app(BladeService::class)); expect($foldable->fold())->toEqualCollapsingWhitespace( @@ -77,7 +77,7 @@ test('merges aware props from parent attributes', function () { $input = ''; - $node = app(Parser::class)->parse($input)[0]; + $node = app(Parser::class)->parse($input)->nodes[0]; $foldable = new Foldable($node, app(ComponentRepository::class)->get('foldable.input-aware'), app(BladeRenderer::class), app(BladeService::class)); $node->setParentsAttributes([ @@ -97,7 +97,7 @@ test('merges dynamic aware props from parent attributes', function () { $input = ''; - $node = app(Parser::class)->parse($input)[0]; + $node = app(Parser::class)->parse($input)->nodes[0]; $node->setParentsAttributes([ 'type' => new Attribute( name: 'type', @@ -119,7 +119,7 @@ test('folds dynamic attributes passed through attribute bag', function () { $input = ''; - $node = app(Parser::class)->parse($input)[0]; + $node = app(Parser::class)->parse($input)->nodes[0]; $foldable = new Foldable($node, app(ComponentRepository::class)->get('foldable.input'), app(BladeRenderer::class), app(BladeService::class)); expect($foldable->fold())->toEqualCollapsingWhitespace( @@ -133,7 +133,7 @@ test('folds dynamic attributes reused under a different key', function () { $input = ''; - $node = app(Parser::class)->parse($input)[0]; + $node = app(Parser::class)->parse($input)->nodes[0]; $foldable = new Foldable($node, app(ComponentRepository::class)->get('foldable.button'), app(BladeRenderer::class), app(BladeService::class)); expect($foldable->fold())->toEqualCollapsingWhitespace( '' @@ -143,7 +143,7 @@ test('wraps output with aware macros if descendants use aware', function () { $input = ''; - $node = app(Parser::class)->parse($input)[0]; + $node = app(Parser::class)->parse($input)->nodes[0]; $node->hasAwareDescendants = true; $foldable = new Foldable($node, app(ComponentRepository::class)->get('foldable.wrapper'), app(BladeRenderer::class), app(BladeService::class)); @@ -158,7 +158,7 @@ test('compiles dynamic attributes in aware macros', function () { $input = ''; - $node = app(Parser::class)->parse($input)[0]; + $node = app(Parser::class)->parse($input)->nodes[0]; $node->hasAwareDescendants = true; $foldable = new Foldable($node, app(ComponentRepository::class)->get('foldable.wrapper'), app(BladeRenderer::class), app(BladeService::class)); @@ -173,7 +173,7 @@ test('compiles echo attributes in aware macros', function () { $input = ''; - $node = app(Parser::class)->parse($input)[0]; + $node = app(Parser::class)->parse($input)->nodes[0]; $node->hasAwareDescendants = true; $foldable = new Foldable($node, app(ComponentRepository::class)->get('foldable.wrapper'), app(BladeRenderer::class), app(BladeService::class)); diff --git a/tests/Folder/FolderTest.php b/tests/Folder/FolderTest.php index 45f70d24..82fa2309 100644 --- a/tests/Folder/FolderTest.php +++ b/tests/Folder/FolderTest.php @@ -11,7 +11,7 @@ test('folds components with static attributes', function () { $input = ''; - $node = app(Parser::class)->parse($input)[0]; + $node = app(Parser::class)->parse($input)->nodes[0]; $folded = app(Folder::class)->fold($node); expect($folded)->toBeInstanceOf(TextNode::class); @@ -20,7 +20,7 @@ test('does not fold components with dynamic prop attributes', function () { $input = ''; - $node = app(Parser::class)->parse($input)[0]; + $node = app(Parser::class)->parse($input)->nodes[0]; $folded = app(Folder::class)->fold($node); expect($folded)->toBeInstanceOf(ComponentNode::class); @@ -29,7 +29,7 @@ test('folds components with dynamic non-prop attributes', function () { $input = ''; - $node = app(Parser::class)->parse($input)[0]; + $node = app(Parser::class)->parse($input)->nodes[0]; $folded = app(Folder::class)->fold($node); expect($folded)->toBeInstanceOf(TextNode::class); @@ -38,7 +38,7 @@ test('folds components with dynamic prop attributes with boolean values', function ($value) { $input = ''; - $node = app(Parser::class)->parse($input)[0]; + $node = app(Parser::class)->parse($input)->nodes[0]; $folded = app(Folder::class)->fold($node); expect($folded)->toBeInstanceOf(TextNode::class); @@ -47,7 +47,7 @@ test('folds components with dynamic prop attributes with null value', function () { $input = ''; - $node = app(Parser::class)->parse($input)[0]; + $node = app(Parser::class)->parse($input)->nodes[0]; $folded = app(Folder::class)->fold($node); expect($folded)->toBeInstanceOf(TextNode::class); @@ -56,7 +56,7 @@ test('fold components with dynamic prop attributes marked as safe', function () { $input = ''; - $node = app(Parser::class)->parse($input)[0]; + $node = app(Parser::class)->parse($input)->nodes[0]; $folded = app(Folder::class)->fold($node); expect($folded)->toBeInstanceOf(TextNode::class); @@ -65,7 +65,7 @@ test('does not fold components with dynamic non-prop attributes marked as unsafe', function () { $input = ''; - $node = app(Parser::class)->parse($input)[0]; + $node = app(Parser::class)->parse($input)->nodes[0]; $folded = app(Folder::class)->fold($node); expect($folded)->toBeInstanceOf(ComponentNode::class); @@ -74,7 +74,7 @@ test('does not fold components with attribute spread', function () { $input = ''; - $node = app(Parser::class)->parse($input)[0]; + $node = app(Parser::class)->parse($input)->nodes[0]; $folded = app(Folder::class)->fold($node); expect($folded)->toBeInstanceOf(ComponentNode::class); @@ -83,7 +83,7 @@ test('folds components with slots', function () { $input = 'HeaderBody'; - $node = app(Parser::class)->parse($input)[0]; + $node = app(Parser::class)->parse($input)->nodes[0]; $folded = app(Folder::class)->fold($node); expect($folded)->toBeInstanceOf(TextNode::class); @@ -92,7 +92,7 @@ test('does not fold components with slots marked as unsafe', function () { $input = 'BodyFooter'; - $node = app(Parser::class)->parse($input)[0]; + $node = app(Parser::class)->parse($input)->nodes[0]; $folded = app(Folder::class)->fold($node); expect($folded)->toBeInstanceOf(ComponentNode::class); @@ -101,7 +101,7 @@ test('does not fold components with dynamic slot attributes', function () { $input = 'HeaderBody'; - $node = app(Parser::class)->parse($input)[0]; + $node = app(Parser::class)->parse($input)->nodes[0]; $folded = app(Folder::class)->fold($node); expect($folded)->toBeInstanceOf(ComponentNode::class); @@ -110,7 +110,7 @@ test('folds components with dynamic prop attributes with safe wildcard', function () { $input = ''; - $node = app(Parser::class)->parse($input)[0]; + $node = app(Parser::class)->parse($input)->nodes[0]; $folded = app(Folder::class)->fold($node); expect($folded)->toBeInstanceOf(TextNode::class); @@ -119,7 +119,7 @@ test('does not fold components with dynamic non-prop attributes with unsafe wildcard', function () { $input = ''; - $node = app(Parser::class)->parse($input)[0]; + $node = app(Parser::class)->parse($input)->nodes[0]; $folded = app(Folder::class)->fold($node); expect($folded)->toBeInstanceOf(ComponentNode::class); @@ -128,7 +128,7 @@ test('folds components without static attributes with unsafe wildcard', function () { $input = ''; - $node = app(Parser::class)->parse($input)[0]; + $node = app(Parser::class)->parse($input)->nodes[0]; $folded = app(Folder::class)->fold($node); expect($folded)->toBeInstanceOf(TextNode::class); @@ -137,7 +137,7 @@ test('does not fold components with slots with unsafe wildcard', function () { $input = 'Body'; - $node = app(Parser::class)->parse($input)[0]; + $node = app(Parser::class)->parse($input)->nodes[0]; $folded = app(Folder::class)->fold($node); expect($folded)->toBeInstanceOf(ComponentNode::class); @@ -146,7 +146,7 @@ test('does not fold components with default slot with unsafe slot keyword', function () { $input = 'Body'; - $node = app(Parser::class)->parse($input)[0]; + $node = app(Parser::class)->parse($input)->nodes[0]; $folded = app(Folder::class)->fold($node); expect($folded)->toBeInstanceOf(ComponentNode::class); @@ -155,7 +155,7 @@ test('does not fold components with explicit default slot with unsafe slot keyword', function () { $input = 'Body'; - $node = app(Parser::class)->parse($input)[0]; + $node = app(Parser::class)->parse($input)->nodes[0]; $folded = app(Folder::class)->fold($node); expect($folded)->toBeInstanceOf(ComponentNode::class); @@ -164,7 +164,7 @@ test('folds components with named only slots with unsafe slot keyword', function () { $input = 'Footer'; - $node = app(Parser::class)->parse($input)[0]; + $node = app(Parser::class)->parse($input)->nodes[0]; $folded = app(Folder::class)->fold($node); expect($folded)->toBeInstanceOf(TextNode::class); @@ -175,7 +175,7 @@ Footer '; - $node = app(Parser::class)->parse($input)[0]; + $node = app(Parser::class)->parse($input)->nodes[0]; $folded = app(Folder::class)->fold($node); expect($folded)->toBeInstanceOf(TextNode::class); @@ -184,7 +184,7 @@ test('does not fold components with dynamic non-prop attributes with unsafe attributes keyword', function () { $input = ''; - $node = app(Parser::class)->parse($input)[0]; + $node = app(Parser::class)->parse($input)->nodes[0]; $folded = app(Folder::class)->fold($node); expect($folded)->toBeInstanceOf(ComponentNode::class); @@ -193,7 +193,7 @@ test('folds components with static non-prop attributes with unsafe attributes keyword', function () { $input = ''; - $node = app(Parser::class)->parse($input)[0]; + $node = app(Parser::class)->parse($input)->nodes[0]; $folded = app(Folder::class)->fold($node); expect($folded)->toBeInstanceOf(TextNode::class); @@ -202,7 +202,7 @@ test('does not fold components with dynamic aware prop from parent', function () { $input = ''; - $node = app(Parser::class)->parse($input)[0]; + $node = app(Parser::class)->parse($input)->nodes[0]; $node->setParentsAttributes( app(AttributeParser::class)->parse(':type="$type"') ); @@ -215,7 +215,7 @@ test('folds components with aware prop overridden by direct attribute', function () { $input = ''; - $node = app(Parser::class)->parse($input)[0]; + $node = app(Parser::class)->parse($input)->nodes[0]; $node->setParentsAttributes( app(AttributeParser::class)->parse(':type="$type"') ); @@ -228,7 +228,7 @@ test('folds components with static aware prop from parent', function () { $input = ''; - $node = app(Parser::class)->parse($input)[0]; + $node = app(Parser::class)->parse($input)->nodes[0]; $node->setParentsAttributes( app(AttributeParser::class)->parse(':type="true"') ); @@ -241,7 +241,7 @@ test('does not fold components with no blaze directive', function () { $input = ''; - $node = app(Parser::class)->parse($input)[0]; + $node = app(Parser::class)->parse($input)->nodes[0]; $folded = app(Folder::class)->fold($node); expect($folded)->toBeInstanceOf(ComponentNode::class); @@ -252,7 +252,7 @@ app(Config::class)->add(fixture_path('views/components/foldable'), fold: true); - $node = app(Parser::class)->parse($input)[0]; + $node = app(Parser::class)->parse($input)->nodes[0]; $compiled = app(Folder::class)->fold($node); expect($compiled)->toBeInstanceOf(ComponentNode::class); @@ -263,7 +263,7 @@ app(Config::class)->add(fixture_path('views/components/foldable'), fold: true); - $node = app(Parser::class)->parse($input)[0]; + $node = app(Parser::class)->parse($input)->nodes[0]; $folded = app(Folder::class)->fold($node); expect($folded)->toBeInstanceOf(TextNode::class); @@ -274,7 +274,7 @@ app(Config::class)->add(fixture_path('views/components/foldable'), fold: false); - $node = app(Parser::class)->parse($input)[0]; + $node = app(Parser::class)->parse($input)->nodes[0]; $folded = app(Folder::class)->fold($node); expect($folded)->toBeInstanceOf(TextNode::class); @@ -283,7 +283,7 @@ test('throws exception for components with problematic patterns', function (string $component) { $input = ""; - $node = app(Parser::class)->parse($input)[0]; + $node = app(Parser::class)->parse($input)->nodes[0]; expect(fn () => app(Folder::class)->fold($node)) ->toThrow(InvalidBlazeFoldUsageException::class); @@ -292,7 +292,7 @@ test('does not fold components with slots wrapped in directives', function () { $input = '@if(false)Header@endif'; - $node = app(Parser::class)->parse($input)[0]; + $node = app(Parser::class)->parse($input)->nodes[0]; $result = app(Folder::class)->fold($node); expect($result)->toBeInstanceOf(ComponentNode::class); @@ -301,7 +301,7 @@ test('folds components with nonclosing directives', function () { $input = '@csrfHeader'; - $node = app(Parser::class)->parse($input)[0]; + $node = app(Parser::class)->parse($input)->nodes[0]; $result = app(Folder::class)->fold($node); expect($result)->toBeInstanceOf(TextNode::class); @@ -310,7 +310,7 @@ test('folds components with closing directives outside slot', function () { $input = ' @if(false) before @endif Header @if(false) after @endif '; - $node = app(Parser::class)->parse($input)[0]; + $node = app(Parser::class)->parse($input)->nodes[0]; $result = app(Folder::class)->fold($node); expect($result)->toBeInstanceOf(TextNode::class); @@ -319,7 +319,7 @@ test('folds components with non-closing directive before slot followed by closing directive', function () { $input = '@csrfHeader@if(false)after@endif'; - $node = app(Parser::class)->parse($input)[0]; + $node = app(Parser::class)->parse($input)->nodes[0]; $result = app(Folder::class)->fold($node); expect($result)->toBeInstanceOf(TextNode::class); diff --git a/tests/Folder/UnblazeTest.php b/tests/Folder/UnblazeTest.php index 793eaad2..e85c0216 100644 --- a/tests/Folder/UnblazeTest.php +++ b/tests/Folder/UnblazeTest.php @@ -9,7 +9,7 @@ test('compiles unblaze blocks', function () { $input = ''; - $node = app(Parser::class)->parse($input)[0]; + $node = app(Parser::class)->parse($input)->nodes[0]; $foldable = new Foldable($node, app(ComponentRepository::class)->get('foldable.input-unblaze'), app(BladeRenderer::class), app(BladeService::class)); expect($foldable->fold())->toEqualCollapsingWhitespace( @@ -25,7 +25,7 @@ test('compiles nested unblaze blocks', function () { $input = ''; - $node = app(Parser::class)->parse($input)[0]; + $node = app(Parser::class)->parse($input)->nodes[0]; $foldable = new Foldable($node, app(ComponentRepository::class)->get('foldable.nested-input-unblaze'), app(BladeRenderer::class), app(BladeService::class)); expect($foldable->fold())->toEqualCollapsingWhitespace( @@ -41,7 +41,7 @@ test('folds dynamic attributes used inside unblaze directive', function () { $input = ''; - $node = app(Parser::class)->parse($input)[0]; + $node = app(Parser::class)->parse($input)->nodes[0]; $foldable = new Foldable($node, app(ComponentRepository::class)->get('foldable.input-unblaze'), app(BladeRenderer::class), app(BladeService::class)); expect($foldable->fold())->toEqualCollapsingWhitespace( diff --git a/tests/Memoizer/MemoizerTest.php b/tests/Memoizer/MemoizerTest.php index c4e50db5..b6eef885 100644 --- a/tests/Memoizer/MemoizerTest.php +++ b/tests/Memoizer/MemoizerTest.php @@ -10,7 +10,7 @@ test('memoizes self-closing components', function () { $input = ''; - $node = app(Parser::class)->parse($input)[0]; + $node = app(Parser::class)->parse($input)->nodes[0]; $memoized = app(Memoizer::class)->memoize($node); $path = fixture_path('views/components/memoizable/avatar.blade.php'); @@ -35,7 +35,7 @@ test('handles echo attributes', function () { $input = ''; - $node = app(Parser::class)->parse($input)[0]; + $node = app(Parser::class)->parse($input)->nodes[0]; $memoized = app(Memoizer::class)->memoize($node); expect($memoized->render())->toContain('[\'src\' => \'https://avatars.com/\'.e($user->username)]'); @@ -49,7 +49,7 @@ BLADE ; - $node = app(Parser::class)->parse($input)[0]; + $node = app(Parser::class)->parse($input)->nodes[0]; $memoized = app(Memoizer::class)->memoize($node); expect($memoized)->toBeInstanceOf(ComponentNode::class); @@ -60,7 +60,7 @@ app(Config::class)->add(fixture_path('views/components/memoizable'), memo: true); - $node = app(Parser::class)->parse($input)[0]; + $node = app(Parser::class)->parse($input)->nodes[0]; $memoized = app(Memoizer::class)->memoize($node); expect($memoized)->toBeInstanceOf(TextNode::class); @@ -71,7 +71,7 @@ app(Config::class)->add(fixture_path('views/components/memoizable'), memo: true); - $node = app(Parser::class)->parse($input)[0]; + $node = app(Parser::class)->parse($input)->nodes[0]; $compiled = app(Memoizer::class)->memoize($node); expect($compiled)->toBeInstanceOf(ComponentNode::class); diff --git a/tests/Parser/ParserTest.php b/tests/Parser/ParserTest.php index 2b85173a..8ec4527c 100644 --- a/tests/Parser/ParserTest.php +++ b/tests/Parser/ParserTest.php @@ -14,7 +14,7 @@ test('parses self-closing components', function () { $input = ''; - expect(app(Parser::class)->parse($input))->toEqual([ + expect(app(Parser::class)->parse($input)->nodes)->toEqual([ new ComponentNode( name: 'button', prefix: 'x-', @@ -28,7 +28,7 @@ test('parses flux components', function () { $input = ''; - expect(app(Parser::class)->parse($input))->toEqual([ + expect(app(Parser::class)->parse($input)->nodes)->toEqual([ new ComponentNode( name: 'flux::button', prefix: 'flux:', @@ -42,7 +42,7 @@ test('parses named slots', function () { $input = 'Footer'; - expect(app(Parser::class)->parse($input))->toEqual([ + expect(app(Parser::class)->parse($input)->nodes)->toEqual([ new ComponentNode( name: 'card', prefix: 'x-', @@ -63,7 +63,7 @@ test('parses named slots with short syntax', function () { $input = 'Footer'; - expect(app(Parser::class)->parse($input))->toEqual([ + expect(app(Parser::class)->parse($input)->nodes)->toEqual([ new ComponentNode( name: 'card', prefix: 'x-', @@ -85,7 +85,7 @@ test('parses named slots with short syntax and name in close tag', function () { $input = 'Footer'; - expect(app(Parser::class)->parse($input))->toEqual([ + expect(app(Parser::class)->parse($input)->nodes)->toEqual([ new ComponentNode( name: 'card', prefix: 'x-', @@ -108,7 +108,7 @@ test('parses explicit default slot', function () { $input = 'Body'; - expect(app(Parser::class)->parse($input))->toEqual([ + expect(app(Parser::class)->parse($input)->nodes)->toEqual([ new ComponentNode( name: 'card', prefix: 'x-', @@ -127,7 +127,7 @@ }); test('parses component prefixes', function ($input, $prefix, $name) { - expect(app(Parser::class)->parse($input))->toEqual([ + expect(app(Parser::class)->parse($input)->nodes)->toEqual([ new ComponentNode($name, $prefix), ]); })->with([ @@ -138,7 +138,7 @@ ]); test('preprocesses attributes using Laravel pipeline', function ($input, $expected) { - $result = app(Parser::class)->parse($input); + $result = app(Parser::class)->parse($input)->nodes; expect($result[0]->render())->toBe($expected); })->with([ @@ -163,7 +163,7 @@ test('parses directives', function () { $input = '@csrf'; - expect(app(Parser::class)->parse($input))->toEqual([ + expect(app(Parser::class)->parse($input)->nodes)->toEqual([ new DirectiveNode('csrf', $input), ]); }); @@ -171,7 +171,7 @@ test('parses PHP and verbatim blocks', function () { $input = ' @verbatim @endverbatim @php echo "footer"; @endphp '; - expect(app(Parser::class)->parse($input))->toEqual([ + expect(app(Parser::class)->parse($input)->nodes)->toEqual([ new ComponentNode( name: 'card', prefix: 'x-', diff --git a/tests/Support/DirectivesTest.php b/tests/Support/DirectivesTest.php index e8da7dce..17203c87 100644 --- a/tests/Support/DirectivesTest.php +++ b/tests/Support/DirectivesTest.php @@ -6,7 +6,7 @@ test('parses arrays', function () { $directives = new Directives( - app(Parser::class)->parse('@aware([\'name\' => null, \'value\'])') + app(Parser::class)->parse('@aware([\'name\' => null, \'value\'])')->nodes ); expect($directives->array('aware'))->toBe(['name' => null, 'value']); @@ -14,7 +14,7 @@ test('parses props', function () { $directives = new Directives( - app(Parser::class)->parse('@props([\'name\' => null, \'value\'])') + app(Parser::class)->parse('@props([\'name\' => null, \'value\'])')->nodes ); expect($directives->props())->toBe(['name', 'value']); @@ -22,7 +22,7 @@ test('parses blaze directive', function () { $directives = new Directives( - app(Parser::class)->parse('@blaze') + app(Parser::class)->parse('@blaze')->nodes ); expect($directives->has('blaze'))->toBeTrue(); @@ -31,7 +31,7 @@ test('parses blaze directive with params', function () { $directives = new Directives( - app(Parser::class)->parse('@blaze(fold: true, safe: [\'name\'])') + app(Parser::class)->parse('@blaze(fold: true, safe: [\'name\'])')->nodes ); expect($directives->blaze())->toBeTrue(); From 3750eac3669ca9fe9370fdca8b286168212e059f Mon Sep 17 00:00:00 2001 From: Filip Ganyicz Date: Thu, 6 Aug 2026 22:05:30 +0200 Subject: [PATCH 12/56] Cache props and aware --- src/BlazeManager.php | 2 -- src/Support/Directives.php | 32 ++++++++++++++++++++++++-------- 2 files changed, 24 insertions(+), 10 deletions(-) diff --git a/src/BlazeManager.php b/src/BlazeManager.php index b0703e15..9da64604 100644 --- a/src/BlazeManager.php +++ b/src/BlazeManager.php @@ -14,11 +14,9 @@ use Livewire\Blaze\Folder\Folder; use Livewire\Blaze\Memoizer\Memoizer; use Livewire\Blaze\Parser\Nodes\ComponentNode; -use Livewire\Blaze\Parser\Nodes\DirectiveNode; use Livewire\Blaze\Parser\Parser; use Livewire\Blaze\Parser\Tokenizer; use Livewire\Blaze\Parser\Walker; -use Livewire\Blaze\Support\Directives; use Livewire\Blaze\Parser\Nodes\SlotNode; use Livewire\Blaze\Support\AttributeParser; use Livewire\Blaze\Support\ComponentRepository; diff --git a/src/Support/Directives.php b/src/Support/Directives.php index e05f0ad0..0f6ced0d 100644 --- a/src/Support/Directives.php +++ b/src/Support/Directives.php @@ -10,12 +10,16 @@ */ class Directives { - /** @var array */ + /** @var array */ protected array $directives; + protected array $props; + protected array $aware; + protected array $blaze; + public function __construct(array $nodes) { - $this->directives = Arr::mapWithKeys($nodes, fn ($node) => [$node->name => $node]); + $this->directives = Arr::keyBy($nodes, 'name'); } /** @@ -53,11 +57,15 @@ public function array(string $name): array|null */ public function props(): array { + if (isset($this->props)) { + return $this->props; + } + if ($definition = $this->array('props')) { - return collect($definition)->map(fn ($value, $key) => is_int($key) ? $value : $key)->values()->all(); + return $this->props = collect($definition)->map(fn ($value, $key) => is_int($key) ? $value : $key)->values()->all(); } - return []; + return $this->props = []; } /** @@ -67,11 +75,15 @@ public function props(): array */ public function aware(): array { + if (isset($this->aware)) { + return $this->aware; + } + if ($definition = $this->array('aware')) { - return collect($definition)->map(fn ($value, $key) => is_int($key) ? $value : $key)->values()->all(); + return $this->aware = collect($definition)->map(fn ($value, $key) => is_int($key) ? $value : $key)->values()->all(); } - return []; + return $this->aware = []; } /** @@ -83,10 +95,14 @@ public function blaze(?string $param = null): mixed return $this->has('blaze'); } + if (array_key_exists($param, $this->blaze)) { + return $this->blaze[$param]; + } + if ($expression = $this->get('blaze')) { - return Utils::parseBlazeDirective($expression)[$param] ?? null; + return $this->blaze[$param] = Utils::parseBlazeDirective($expression)[$param] ?? null; } - return null; + return $this->blaze[$param] = null; } } From 638ee78fc7e24683c82e04c39ae809b69cd741ed Mon Sep 17 00:00:00 2001 From: Filip Ganyicz Date: Fri, 7 Aug 2026 17:56:34 +0200 Subject: [PATCH 13/56] Refactor unblaze to use AST --- src/BladeRenderer.php | 8 +-- src/BlazeManager.php | 98 +++++++++++++++++++++++------------- src/Support/Directives.php | 2 +- src/Unblaze.php | 47 +++-------------- tests/Folder/UnblazeTest.php | 3 ++ 5 files changed, 75 insertions(+), 83 deletions(-) diff --git a/src/BladeRenderer.php b/src/BladeRenderer.php index 8f3c50f8..0e552c19 100644 --- a/src/BladeRenderer.php +++ b/src/BladeRenderer.php @@ -69,13 +69,7 @@ public function render(ComponentNode $component, ComponentSource $source): strin 'footer' => [], 'prepareStringsForCompilationUsing' => [ function ($input) { - if (Unblaze::hasUnblaze($input)) { - $input = Unblaze::processUnblazeDirectives($input); - }; - - $input = $this->manager->compileForFolding($input, $this->blade->getPath()); - - return $input; + return $this->manager->compileForFolding($input, $this->blade->getPath()); }, ], 'path' => null, diff --git a/src/BlazeManager.php b/src/BlazeManager.php index 9da64604..97d3b26b 100644 --- a/src/BlazeManager.php +++ b/src/BlazeManager.php @@ -14,12 +14,17 @@ use Livewire\Blaze\Folder\Folder; use Livewire\Blaze\Memoizer\Memoizer; use Livewire\Blaze\Parser\Nodes\ComponentNode; +use Livewire\Blaze\Parser\Nodes\DirectiveNode; use Livewire\Blaze\Parser\Parser; use Livewire\Blaze\Parser\Tokenizer; use Livewire\Blaze\Parser\Walker; use Livewire\Blaze\Parser\Nodes\SlotNode; use Livewire\Blaze\Support\AttributeParser; use Livewire\Blaze\Support\ComponentRepository; +use Livewire\Blaze\Support\DirectiveStack; +use Livewire\Blaze\Support\DirectiveStructure; +use Livewire\Blaze\Parser\Nodes\Node; +use Livewire\Blaze\Parser\Nodes\TextNode; class BlazeManager { @@ -32,7 +37,8 @@ class BlazeManager protected $foldedEvents = []; protected $expiredMemo = []; - protected Parser $parser; + public readonly Parser $parser; + protected Walker $walker; protected Compiler $compiler; protected Folder $folder; @@ -121,6 +127,63 @@ public function compile(string $source, ?string $path = null): string return $output; } + /** + * Compile for folding context - only tag compiler and component compiler. + * No folding or memoization to avoid infinite recursion. + */ + public function compileForFolding(string $source, ?string $path = null): string + { + $template = $this->parser->parse($source, $path); + + $currentUnblazeToken = null; + + $ast = $this->walker->walk( + nodes: $template->nodes, + preCallback: function (Node $node) use (&$currentUnblazeToken) { + if ($node instanceof DirectiveNode && $node->name === 'unblaze') { + $currentUnblazeToken = str()->random(10); + $tag = '[STARTCOMPILEDUNBLAZE:' . $currentUnblazeToken . ']'; + $content = 'expression . '); ?>'; + + return new TextNode($tag . $content); + } + + if ($node instanceof DirectiveNode && $node->name === 'endunblaze' && $currentUnblazeToken) { + $tag = '[ENDCOMPILEDUNBLAZE:' . $currentUnblazeToken . ']'; + + $currentUnblazeToken = null; + + return new TextNode($tag); + } + + if ($currentUnblazeToken) { + Unblaze::storeReplacement($currentUnblazeToken, $node->render()); + + return new TextNode(''); + } + }, + postCallback: function ($node) { + return $this->compiler->compile($node); + }, + ); + + $output = $this->render($ast); + + if (! $path) { + return $output; + } + + $shouldWrap = $this->config->shouldFold($path) + || $this->config->shouldMemoize($path) + || $this->config->shouldCompile($path); + + if ($template->directives->blaze() || $shouldWrap) { + $output = $this->render($this->wrapper->wrap($ast, $path)); + } + + return $output; + } + /** * Compile a template within an @unblaze block (no folding, no wrapping). */ @@ -181,39 +244,6 @@ public function compileForDebug(string $source, ?string $path = null): string return $output; } - /** - * Compile for folding context - only tag compiler and component compiler. - * No folding or memoization to avoid infinite recursion. - */ - public function compileForFolding(string $source, ?string $path = null): string - { - $template = $this->parser->parse($source, $path); - - $ast = $this->walker->walk( - nodes: $template->nodes, - preCallback: fn ($node) => $node, - postCallback: function ($node) { - return $this->compiler->compile($node); - }, - ); - - $output = $this->render($ast); - - if (! $path) { - return $output; - } - - $shouldWrap = $this->config->shouldFold($path) - || $this->config->shouldMemoize($path) - || $this->config->shouldCompile($path); - - if ($template->directives->blaze() || $shouldWrap) { - $output = $this->render($this->wrapper->wrap($ast, $path)); - } - - return $output; - } - /** * Flush and return all collected ComponentFolded events. */ diff --git a/src/Support/Directives.php b/src/Support/Directives.php index 0f6ced0d..e3b4114d 100644 --- a/src/Support/Directives.php +++ b/src/Support/Directives.php @@ -95,7 +95,7 @@ public function blaze(?string $param = null): mixed return $this->has('blaze'); } - if (array_key_exists($param, $this->blaze)) { + if (isset($this->blaze) && array_key_exists($param, $this->blaze)) { return $this->blaze[$param]; } diff --git a/src/Unblaze.php b/src/Unblaze.php index 2fffbb35..f841ac89 100644 --- a/src/Unblaze.php +++ b/src/Unblaze.php @@ -17,53 +17,18 @@ class Unblaze /** * Store runtime scope data for an @unblaze token. */ - public static function storeScope($token, $scope = []) + public static function storeScope(string $token, $scope = []) { static::$unblazeScopes[$token] = $scope; } /** - * Check if a template contains @unblaze directives. - */ - public static function hasUnblaze(string $template): bool - { - return str_contains($template, '@unblaze'); - } - - /** - * Replace @unblaze/@endunblaze blocks with placeholders before Blaze compilation. + * Store runtime scope data for an @unblaze token. */ - public static function processUnblazeDirectives(string $template) + public static function storeReplacement(string $token, string $replacement) { - $expressionsByToken = []; - - $result = DirectiveCompiler::make() - ->directive('unblaze', function ($expression) use (&$expressionsByToken) { - $token = str()->random(10); - - $expressionsByToken[$token] = $expression; - - return '[STARTUNBLAZE:'.$token.']'; - }) - ->directive('endunblaze', function () { - return '[ENDUNBLAZE]'; - }) - ->compile($template); - - $result = preg_replace_callback('/(\[STARTUNBLAZE:([0-9a-zA-Z]+)\])(.*?)(\[ENDUNBLAZE\])/s', function ($matches) use (&$expressionsByToken) { - $token = $matches[2]; - $expression = $expressionsByToken[$token]; - $innerContent = $matches[3]; - - static::$unblazeReplacements[$token] = $innerContent; - - return '' - . '[STARTCOMPILEDUNBLAZE:'.$token.']' - . '<'.'?php \Livewire\Blaze\Unblaze::storeScope("'.$token.'", '.$expression.') ?>' - . '[ENDCOMPILEDUNBLAZE:'.$token.']'; - }, $result); - - return $result; + static::$unblazeReplacements[$token] ??= ''; + static::$unblazeReplacements[$token] .= $replacement; } /** @@ -72,7 +37,7 @@ public static function processUnblazeDirectives(string $template) public static function replaceUnblazePrecompiledDirectives(string $template) { if (str_contains($template, '[STARTCOMPILEDUNBLAZE')) { - $template = preg_replace_callback('/(\[STARTCOMPILEDUNBLAZE:([0-9a-zA-Z:]+)?\])(.*?)(\[ENDCOMPILEDUNBLAZE:\2\])(\r?\n)?/s', function ($matches) use (&$expressionsByToken) { + $template = preg_replace_callback('/(\[STARTCOMPILEDUNBLAZE:([0-9a-zA-Z:]+)?\])(.*?)(\[ENDCOMPILEDUNBLAZE:\2\])(\r?\n)?/s', function ($matches) { $token = $matches[2]; // Because unblaze content is not available at render-time during folding, diff --git a/tests/Folder/UnblazeTest.php b/tests/Folder/UnblazeTest.php index e85c0216..81dd8723 100644 --- a/tests/Folder/UnblazeTest.php +++ b/tests/Folder/UnblazeTest.php @@ -1,11 +1,14 @@ Artisan::call('view:clear')); + test('compiles unblaze blocks', function () { $input = ''; From f9a79c18d0eed3c6b2f691c1e30fe8a1a81f18b8 Mon Sep 17 00:00:00 2001 From: Filip Ganyicz Date: Fri, 7 Aug 2026 17:57:00 +0200 Subject: [PATCH 14/56] Remove DirectiveCompiler --- src/BladeService.php | 2 - src/Compiler/DirectiveCompiler.php | 99 ------------------------------ src/Unblaze.php | 1 - 3 files changed, 102 deletions(-) delete mode 100644 src/Compiler/DirectiveCompiler.php diff --git a/src/BladeService.php b/src/BladeService.php index 63a2001b..d0b9ef9e 100644 --- a/src/BladeService.php +++ b/src/BladeService.php @@ -7,9 +7,7 @@ use Illuminate\View\Compilers\BladeCompiler; use Illuminate\View\Compilers\ComponentTagCompiler; use Illuminate\View\Factory; -use Livewire\Blaze\Compiler\DirectiveCompiler; use Livewire\Blaze\Parser\Attribute; -use Livewire\Blaze\Support\LaravelRegex; use ReflectionClass; class BladeService diff --git a/src/Compiler/DirectiveCompiler.php b/src/Compiler/DirectiveCompiler.php deleted file mode 100644 index 52b2adc0..00000000 --- a/src/Compiler/DirectiveCompiler.php +++ /dev/null @@ -1,99 +0,0 @@ - */ - protected array $directives = []; - - /** - * Create a new DirectiveCompiler instance. - */ - public static function make(): static - { - return new static(); - } - - /** - * Register a directive on this compiler instance. - */ - public function directive(string $name, callable $handler): static - { - $this->directives[$name] = $handler; - - return $this; - } - - /** - * Compile all registered directives within a template using a sandboxed Blade compiler. - */ - public function compile(string $template): string - { - $compiler = $this->createSandboxedCompiler(); - - foreach ($this->directives as $name => $handler) { - $compiler->directive($name, $handler); - } - - return $compiler->compileStatementsMadePublic($template); - } - - /** - * Create a BladeCompiler that only processes custom directives, ignoring built-in ones. - */ - private function createSandboxedCompiler() - { - return new class(new Filesystem, sys_get_temp_dir()) extends BladeCompiler - { - public function compileStatementsMadePublic($template) - { - $result = ''; - - foreach (token_get_all($template) as $token) { - if (! is_array($token)) { - $result .= $token; - - continue; - } - - [$id, $content] = $token; - - if ($id == T_INLINE_HTML) { - $result .= $this->compileStatements($content); - } else { - $result .= $content; - } - } - - return $result; - } - - /** - * Only process custom directives, skip built-in ones. - */ - protected function compileStatement($match) - { - if (str_contains($match[1], '@')) { - return $match[0]; - } elseif (isset($this->customDirectives[$match[1]])) { - $match[0] = $this->callCustomDirective($match[1], Arr::get($match, 3)); - } elseif (method_exists($this, $method = 'compile'.ucfirst($match[1]))) { - return $match[0]; - } else { - return $match[0]; - } - - return isset($match[3]) ? $match[0] : $match[0].$match[2]; - } - }; - } -} diff --git a/src/Unblaze.php b/src/Unblaze.php index f841ac89..0b2ccd31 100644 --- a/src/Unblaze.php +++ b/src/Unblaze.php @@ -2,7 +2,6 @@ namespace Livewire\Blaze; -use Livewire\Blaze\Compiler\DirectiveCompiler; use Illuminate\Support\Str; /** From 267e3dc745ab67d4758604dabbef42285abee4aa Mon Sep 17 00:00:00 2001 From: Filip Ganyicz Date: Fri, 7 Aug 2026 17:58:58 +0200 Subject: [PATCH 15/56] Delete DirectiveCompilerTest.php --- tests/Compiler/DirectiveCompilerTest.php | 34 ------------------------ 1 file changed, 34 deletions(-) delete mode 100644 tests/Compiler/DirectiveCompilerTest.php diff --git a/tests/Compiler/DirectiveCompilerTest.php b/tests/Compiler/DirectiveCompilerTest.php deleted file mode 100644 index 63fa1759..00000000 --- a/tests/Compiler/DirectiveCompilerTest.php +++ /dev/null @@ -1,34 +0,0 @@ -directive('custom', fn ($expression) => "") - ->directive('endcustom', fn () => "") - ->compile($input); - - expect($result)->toBe('@if($condition) @endif'); -}); - -test('ignores escaped directives', function () { - $input = '@@custom($value)'; - - $result = DirectiveCompiler::make() - ->directive('custom', fn () => '') - ->compile($input); - - expect($result)->toBe($input); -}); - -test('ignores php blocks', function () { - $input = ''; - - $result = DirectiveCompiler::make() - ->directive('custom', fn () => '') - ->compile($input); - - expect($result)->toBe($input); -}); From 78a2d5d37df75451dd380a23a29b2ff3dff5bd3e Mon Sep 17 00:00:00 2001 From: Filip Ganyicz Date: Fri, 7 Aug 2026 20:56:27 +0200 Subject: [PATCH 16/56] Refactor --- src/Compiler/UseExtractor.php | 72 ++++++++++++++--------------- src/Parser/Nodes/PhpBlockNode.php | 2 +- src/Parser/Parser.php | 2 +- tests/Compiler/UseExtractorTest.php | 38 +-------------- tests/Parser/ParserTest.php | 2 +- 5 files changed, 39 insertions(+), 77 deletions(-) diff --git a/src/Compiler/UseExtractor.php b/src/Compiler/UseExtractor.php index f9c8900a..fc6544c5 100644 --- a/src/Compiler/UseExtractor.php +++ b/src/Compiler/UseExtractor.php @@ -11,58 +11,56 @@ */ class UseExtractor { + protected Parser $parser; + + public function __construct() + { + $this->parser = app(Parser::class); + } + /** * Extract use statements from blocks in the compiled template. * * Uses php-parser to find the boundary between use statements and code, * then splits the original text at that point — no re-printing. */ - public function extract(string $compiled, callable $callback): string + public function extract(string $php, callable $callback): string { - return preg_replace_callback('/<\?php(.*?)\?>|(?parser->parse($php); + } catch (\Throwable) { + return $php; + } - try { - $ast = app(Parser::class)->parse($block); - } catch (\Throwable) { - return $match[0]; - } - - if (! $ast) { - return $match[0]; - } + if (! $ast) { + return $php; + } - $lastUseEnd = null; + $lastUseEnd = null; - foreach ($ast as $stmt) { - if (! $stmt instanceof Use_ && ! $stmt instanceof GroupUse) { - break; - } - - $start = $stmt->getStartFilePos(); - $end = $stmt->getEndFilePos(); - - $callback(substr($block, $start, $end - $start + 1)); - - $lastUseEnd = $end; + foreach ($ast as $stmt) { + if (! $stmt instanceof Use_ && ! $stmt instanceof GroupUse) { + break; } - if ($lastUseEnd === null) { - return $match[0]; - } + $start = $stmt->getStartFilePos(); + $end = $stmt->getEndFilePos(); - $remaining = ltrim(substr($block, $lastUseEnd + 1)); + $callback(substr($php, $start, $end - $start + 1)); - if ($remaining === '') { - return ''; - } + $lastUseEnd = $end; + } + + if ($lastUseEnd === null) { + return $php; + } - $open = $isDirective ? '@php ' : ''; + $remaining = ltrim(substr($php, $lastUseEnd + 1)); - return $open . $remaining . $close; - }, $compiled); + if (! $remaining) { + return ''; + } + + return ''; } } diff --git a/src/Parser/Nodes/PhpBlockNode.php b/src/Parser/Nodes/PhpBlockNode.php index 1a0d503b..b75fce83 100644 --- a/src/Parser/Nodes/PhpBlockNode.php +++ b/src/Parser/Nodes/PhpBlockNode.php @@ -3,7 +3,7 @@ namespace Livewire\Blaze\Parser\Nodes; /** - * Represents a native PHP block or Blade @php block in the AST. + * Represents a native PHP block in the AST. */ class PhpBlockNode extends Node { diff --git a/src/Parser/Parser.php b/src/Parser/Parser.php index 1692f96e..aab7e11b 100644 --- a/src/Parser/Parser.php +++ b/src/Parser/Parser.php @@ -162,7 +162,7 @@ protected function handleText(TextToken $token, ParseStack $stack): void */ protected function handlePhpBlock(PhpBlockToken $token, ParseStack $stack): void { - $node = new PhpBlockNode(content: $token->content); + $node = new PhpBlockNode(content: str_replace(['@php', '@endphp'], [''], $token->content)); $stack->addToRoot($node); } diff --git a/tests/Compiler/UseExtractorTest.php b/tests/Compiler/UseExtractorTest.php index fbbbea3c..6286e4a7 100644 --- a/tests/Compiler/UseExtractorTest.php +++ b/tests/Compiler/UseExtractorTest.php @@ -50,40 +50,4 @@ expect($statements)->toBe(['use App\Models\User;']) ->and($result)->toBe('
'); -}); - -test('extracts use statements from @php blocks', function () { - $input = "@php use App\Models\User;\nuse App\Models\Order;\nUser::find(1); @endphp"; - - $statements = []; - $result = (new UseExtractor)->extract($input, function ($s) use (&$statements) { $statements[] = $s; }); - - expect($result)->toBe('@php User::find(1); @endphp'); - expect($statements)->toBe(['use App\Models\User;', 'use App\Models\Order;']); -}); - -test('removes @php blocks containing only use statements', function () { - $input = '@php use App\Models\User; @endphp'; - - $statements = []; - $result = (new UseExtractor)->extract($input, function ($s) use (&$statements) { $statements[] = $s; }); - - expect($result)->toBe(''); - expect($statements)->toBe(['use App\Models\User;']); -}); - -test('leaves @php blocks without use statements unchanged', function () { - $input = '@php echo "hello"; @endphp'; - - $result = (new UseExtractor)->extract($input, function () {}); - - expect($result)->toBe($input); -}); - -test('ignores escaped php blocks', function () { - $input = '@@php use App\Models\User; @endphp'; - - $result = (new UseExtractor)->extract($input, function () {}); - - expect($result)->toBe($input); -}); +}); \ No newline at end of file diff --git a/tests/Parser/ParserTest.php b/tests/Parser/ParserTest.php index 8ec4527c..baa77376 100644 --- a/tests/Parser/ParserTest.php +++ b/tests/Parser/ParserTest.php @@ -181,7 +181,7 @@ new TextNode(' '), new VerbatimBlockNode('@verbatim @endverbatim'), new TextNode(' '), - new PhpBlockNode('@php echo "footer"; @endphp'), + new PhpBlockNode(''), new TextNode(' '), ], ), From 340975c3bb1e6ec1c7a8fc90389a1249aa950328 Mon Sep 17 00:00:00 2001 From: Filip Ganyicz Date: Fri, 7 Aug 2026 20:58:06 +0200 Subject: [PATCH 17/56] Refactor --- src/BladeService.php | 11 +++++++++++ src/Compiler/Wrapper.php | 13 +------------ 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/BladeService.php b/src/BladeService.php index d0b9ef9e..6393c2aa 100644 --- a/src/BladeService.php +++ b/src/BladeService.php @@ -203,6 +203,17 @@ public function componentNameToPath($name): string return ''; } + /** + * Check if the Blade compiler has any echo handlers registered. + */ + public function hasEchoHandlers(): bool + { + $reflection = new ReflectionClass($this->compiler); + $handlers = $reflection->getProperty('echoHandlers')->getValue($this->compiler); + + return ! empty($handlers); + } + /** * Determine if a component resolves to a class rather than a blade view. * diff --git a/src/Compiler/Wrapper.php b/src/Compiler/Wrapper.php index d3adc955..0b8246c9 100644 --- a/src/Compiler/Wrapper.php +++ b/src/Compiler/Wrapper.php @@ -116,7 +116,7 @@ protected function globalVariables(array $ast): string '$__env' => '$__env = $__blaze->env', ]; - $hasEchoHandlers = $this->hasEchoHandlers(); + $hasEchoHandlers = $this->blade->hasEchoHandlers(); foreach ((new Walker)->iterate($ast) as $node) { if (! isset($variables['$app']) && $node->usesVariable('$app')) { @@ -147,17 +147,6 @@ protected function globalVariables(array $ast): string return join(";\n", $variables) . ";\n"; } - /** - * Check if the Blade compiler has any echo handlers registered. - */ - protected function hasEchoHandlers(): bool - { - $compiler = $this->blade->compiler; - $reflection = new \ReflectionProperty($compiler, 'echoHandlers'); - - return ! empty($reflection->getValue($compiler)); - } - protected function usesThis(array $ast): bool { foreach ((new Walker)->iterate($ast) as $node) { From 27f89cbdb833de1a21536371ecfb01aa5a291bcb Mon Sep 17 00:00:00 2001 From: Filip Ganyicz Date: Fri, 7 Aug 2026 21:29:30 +0200 Subject: [PATCH 18/56] Refactor --- src/Compiler/Wrapper.php | 25 ++++++++----------------- 1 file changed, 8 insertions(+), 17 deletions(-) diff --git a/src/Compiler/Wrapper.php b/src/Compiler/Wrapper.php index 0b8246c9..adc4d190 100644 --- a/src/Compiler/Wrapper.php +++ b/src/Compiler/Wrapper.php @@ -36,14 +36,16 @@ public function __construct( public function wrap(array $ast, string $path): array { $name = ($this->manager->isFolding() ? '__' : '_') . Utils::hash($path); - - $sourceUsesThis = $this->usesThis($ast); - + $sourceUsesThis = false; $imports = ''; $ast = (new Walker)->walk( nodes: $ast, - preCallback: function ($node) { + preCallback: function ($node) use (&$sourceUsesThis) { + if (! $sourceUsesThis && $node->usesVariable('$this') || $node->isDirective(['entangle', 'script', 'assets'])) { + $sourceUsesThis = true; + } + if ($node instanceof DirectiveNode && $node->name === 'use') { return new PhpBlockNode($this->blade->compileUseStatements($node->expression)); } @@ -82,7 +84,7 @@ public function wrap(array $ast, string $path): array $opening .= '$__blazeFn = function () use ($__blaze, $__data, $__slots, $__bound, $__keys) {'."\n"; } - $opening .= $this->globalVariables($ast); + $opening .= $this->globalVariables($ast)."\n"; $opening .= 'if (($__data[\'attributes\'] ?? null) instanceof \Illuminate\View\ComponentAttributeBag) { $__data = $__data + $__data[\'attributes\']->all(); unset($__data[\'attributes\']); }'."\n"; $opening .= 'extract($__slots, EXTR_SKIP); unset($__slots);'."\n"; $opening .= 'extract($__data, EXTR_SKIP);'."\n"; @@ -144,17 +146,6 @@ protected function globalVariables(array $ast): string } } - return join(";\n", $variables) . ";\n"; - } - - protected function usesThis(array $ast): bool - { - foreach ((new Walker)->iterate($ast) as $node) { - if ($node->usesVariable('$this') || $node->isDirective(['entangle', 'script', 'assets'])) { - return true; - } - } - - return false; + return join(";\n", $variables); } } From 5c40d75ecbdef5b9512afdc70a8d250e70d95053 Mon Sep 17 00:00:00 2001 From: Filip Ganyicz Date: Fri, 7 Aug 2026 21:36:16 +0200 Subject: [PATCH 19/56] Fix --- src/Compiler/Wrapper.php | 2 +- tests/Compiler/WrapperTest.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Compiler/Wrapper.php b/src/Compiler/Wrapper.php index adc4d190..e91972fd 100644 --- a/src/Compiler/Wrapper.php +++ b/src/Compiler/Wrapper.php @@ -84,7 +84,7 @@ public function wrap(array $ast, string $path): array $opening .= '$__blazeFn = function () use ($__blaze, $__data, $__slots, $__bound, $__keys) {'."\n"; } - $opening .= $this->globalVariables($ast)."\n"; + $opening .= $this->globalVariables($ast).";\n"; $opening .= 'if (($__data[\'attributes\'] ?? null) instanceof \Illuminate\View\ComponentAttributeBag) { $__data = $__data + $__data[\'attributes\']->all(); unset($__data[\'attributes\']); }'."\n"; $opening .= 'extract($__slots, EXTR_SKIP); unset($__slots);'."\n"; $opening .= 'extract($__data, EXTR_SKIP);'."\n"; diff --git a/tests/Compiler/WrapperTest.php b/tests/Compiler/WrapperTest.php index d0bdafff..0e9b3444 100644 --- a/tests/Compiler/WrapperTest.php +++ b/tests/Compiler/WrapperTest.php @@ -125,7 +125,7 @@ $ast = app(Parser::class)->parse($input)->nodes; $wrapped = join('', array_map(fn ($node) => $node->render(), app(Wrapper::class)->wrap($ast, ''))); - expect($wrapped)->toContain($input); + expect($wrapped)->toContain(''); }); test('preserves verbatim directives', function () { From 6a676c5f632190a339d43957f05947f0b178587e Mon Sep 17 00:00:00 2001 From: Filip Ganyicz Date: Fri, 7 Aug 2026 22:15:38 +0200 Subject: [PATCH 20/56] Revert "Refactor" --- src/Compiler/UseExtractor.php | 65 ++++++++++++++++++------------- src/Parser/Nodes/PhpBlockNode.php | 2 +- src/Parser/Parser.php | 2 +- tests/Compiler/WrapperTest.php | 2 +- tests/Parser/ParserTest.php | 2 +- 5 files changed, 41 insertions(+), 32 deletions(-) diff --git a/src/Compiler/UseExtractor.php b/src/Compiler/UseExtractor.php index fc6544c5..07456a17 100644 --- a/src/Compiler/UseExtractor.php +++ b/src/Compiler/UseExtractor.php @@ -24,43 +24,52 @@ public function __construct() * Uses php-parser to find the boundary between use statements and code, * then splits the original text at that point — no re-printing. */ - public function extract(string $php, callable $callback): string + public function extract(string $compiled, callable $callback): string { - try { - $ast = $this->parser->parse($php); - } catch (\Throwable) { - return $php; - } + return preg_replace_callback('/<\?php(.*?)\?>|(?parser->parse($block); + } catch (\Throwable) { + return $match[0]; + } - foreach ($ast as $stmt) { - if (! $stmt instanceof Use_ && ! $stmt instanceof GroupUse) { - break; + if (! $ast) { + return $match[0]; } - $start = $stmt->getStartFilePos(); - $end = $stmt->getEndFilePos(); + $lastUseEnd = null; + + foreach ($ast as $stmt) { + if (! $stmt instanceof Use_ && ! $stmt instanceof GroupUse) { + break; + } - $callback(substr($php, $start, $end - $start + 1)); + $start = $stmt->getStartFilePos(); + $end = $stmt->getEndFilePos(); - $lastUseEnd = $end; - } + $callback(substr($block, $start, $end - $start + 1)); + + $lastUseEnd = $end; + } - if ($lastUseEnd === null) { - return $php; - } + if ($lastUseEnd === null) { + return $match[0]; + } + + $remaining = ltrim(substr($block, $lastUseEnd + 1)); + + if ($remaining === '') { + return ''; + } - $remaining = ltrim(substr($php, $lastUseEnd + 1)); + $open = $isDirective ? '@php ' : ''; - if (! $remaining) { - return ''; - } - - return ''; + return $open . $remaining . $close; + }, $compiled); } } diff --git a/src/Parser/Nodes/PhpBlockNode.php b/src/Parser/Nodes/PhpBlockNode.php index b75fce83..1a0d503b 100644 --- a/src/Parser/Nodes/PhpBlockNode.php +++ b/src/Parser/Nodes/PhpBlockNode.php @@ -3,7 +3,7 @@ namespace Livewire\Blaze\Parser\Nodes; /** - * Represents a native PHP block in the AST. + * Represents a native PHP block or Blade @php block in the AST. */ class PhpBlockNode extends Node { diff --git a/src/Parser/Parser.php b/src/Parser/Parser.php index aab7e11b..1692f96e 100644 --- a/src/Parser/Parser.php +++ b/src/Parser/Parser.php @@ -162,7 +162,7 @@ protected function handleText(TextToken $token, ParseStack $stack): void */ protected function handlePhpBlock(PhpBlockToken $token, ParseStack $stack): void { - $node = new PhpBlockNode(content: str_replace(['@php', '@endphp'], [''], $token->content)); + $node = new PhpBlockNode(content: $token->content); $stack->addToRoot($node); } diff --git a/tests/Compiler/WrapperTest.php b/tests/Compiler/WrapperTest.php index 0e9b3444..5be9429a 100644 --- a/tests/Compiler/WrapperTest.php +++ b/tests/Compiler/WrapperTest.php @@ -125,7 +125,7 @@ $ast = app(Parser::class)->parse($input)->nodes; $wrapped = join('', array_map(fn ($node) => $node->render(), app(Wrapper::class)->wrap($ast, ''))); - expect($wrapped)->toContain(''); + expect($wrapped)->toContain('@php /* uncompiled */ @endphp'); }); test('preserves verbatim directives', function () { diff --git a/tests/Parser/ParserTest.php b/tests/Parser/ParserTest.php index baa77376..8ec4527c 100644 --- a/tests/Parser/ParserTest.php +++ b/tests/Parser/ParserTest.php @@ -181,7 +181,7 @@ new TextNode(' '), new VerbatimBlockNode('@verbatim @endverbatim'), new TextNode(' '), - new PhpBlockNode(''), + new PhpBlockNode('@php echo "footer"; @endphp'), new TextNode(' '), ], ), From c453cf98a0191ee7ffcbf5b4530bc8c507791116 Mon Sep 17 00:00:00 2001 From: Filip Ganyicz Date: Fri, 7 Aug 2026 22:18:37 +0200 Subject: [PATCH 21/56] Refactor --- src/Compiler/Wrapper.php | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/src/Compiler/Wrapper.php b/src/Compiler/Wrapper.php index e91972fd..ebbd6377 100644 --- a/src/Compiler/Wrapper.php +++ b/src/Compiler/Wrapper.php @@ -8,6 +8,7 @@ use Livewire\Blaze\Parser\Nodes\DirectiveNode; use Livewire\Blaze\Parser\Nodes\PhpBlockNode; use Livewire\Blaze\Parser\Walker; +use Livewire\Blaze\Compiler\UseExtractor; /** * Compiles Blaze component templates into PHP function definitions. @@ -84,7 +85,7 @@ public function wrap(array $ast, string $path): array $opening .= '$__blazeFn = function () use ($__blaze, $__data, $__slots, $__bound, $__keys) {'."\n"; } - $opening .= $this->globalVariables($ast).";\n"; + $opening .= $this->globalVariables($ast)."\n"; $opening .= 'if (($__data[\'attributes\'] ?? null) instanceof \Illuminate\View\ComponentAttributeBag) { $__data = $__data + $__data[\'attributes\']->all(); unset($__data[\'attributes\']); }'."\n"; $opening .= 'extract($__slots, EXTR_SKIP); unset($__slots);'."\n"; $opening .= 'extract($__data, EXTR_SKIP);'."\n"; @@ -115,37 +116,37 @@ public function wrap(array $ast, string $path): array protected function globalVariables(array $ast): string { $variables = [ - '$__env' => '$__env = $__blaze->env', + '$__env' => '$__env = $__blaze->env;', ]; $hasEchoHandlers = $this->blade->hasEchoHandlers(); foreach ((new Walker)->iterate($ast) as $node) { if (! isset($variables['$app']) && $node->usesVariable('$app')) { - $variables['$app'] = '$app = $__blaze->app'; + $variables['$app'] = '$app = $__blaze->app;'; } if (! isset($variables['$errors']) && ($node->usesVariable('$errors') || $node->isDirective('error'))) { - $variables['$errors'] = '$errors = $__blaze->errors'; + $variables['$errors'] = '$errors = $__blaze->errors;'; } if (! isset($variables['$__livewire']) && ($node->usesVariable('$__livewire') || $node->isDirective('entangle') || $node->isDirective('this'))) { - $variables['$__livewire'] = '$__livewire = $__env->shared(\'__livewire\')'; + $variables['$__livewire'] = '$__livewire = $__env->shared(\'__livewire\');'; } if (! isset($variables['$_instance']) && $node->isDirective('this')) { - $variables['$_instance'] = '$_instance = $__livewire'; + $variables['$_instance'] = '$_instance = $__livewire;'; } if (! isset($variables['$slot']) && $node->usesVariable('$slot')) { - $variables['$slot'] = '$__slots[\'slot\'] ??= new \Illuminate\View\ComponentSlot(\'\')'; + $variables['$slot'] = '$__slots[\'slot\'] ??= new \Illuminate\View\ComponentSlot(\'\');'; } if ($hasEchoHandlers && ! isset($variables['$__bladeCompiler']) && $node->usesEchoSyntax()) { - $variables['$__bladeCompiler'] = '$__bladeCompiler = app(\'blade.compiler\')'; + $variables['$__bladeCompiler'] = '$__bladeCompiler = app(\'blade.compiler\');'; } } - return join(";\n", $variables); + return join("\n", $variables); } } From bb087843fc8c10f3253d0f6412b9236e59c39326 Mon Sep 17 00:00:00 2001 From: Filip Ganyicz Date: Fri, 7 Aug 2026 22:25:21 +0200 Subject: [PATCH 22/56] Make Walker static --- src/BlazeManager.php | 10 ++++------ src/Compiler/Wrapper.php | 4 ++-- src/Parser/Template.php | 4 ++-- src/Parser/Walker.php | 12 ++++++------ src/Runtime/BlazeRuntime.php | 1 - tests/Parser/ParserTest.php | 1 - 6 files changed, 14 insertions(+), 18 deletions(-) diff --git a/src/BlazeManager.php b/src/BlazeManager.php index 97d3b26b..d57efe66 100644 --- a/src/BlazeManager.php +++ b/src/BlazeManager.php @@ -39,7 +39,6 @@ class BlazeManager public readonly Parser $parser; - protected Walker $walker; protected Compiler $compiler; protected Folder $folder; protected Memoizer $memoizer; @@ -56,7 +55,6 @@ public function __construct( ) { $this->renderer = new BladeRenderer($bladeCompiler, app('view'), $this->runtime, $this); $this->parser = new Parser(new Tokenizer($this->blade), new AttributeParser($this->blade)); - $this->walker = new Walker; $this->compiler = new Compiler($config, $this->blade, $this, $this->components); $this->folder = new Folder($config, $this->blade, $this->renderer, $this, $this->components); $this->memoizer = new Memoizer($config, $this->compiler, $this->blade, $this, $this->components); @@ -77,7 +75,7 @@ public function compile(string $source, ?string $path = null): string $template = $this->parser->parse($source, $path); - $ast = $this->walker->walk( + $ast = Walker::walk( nodes: $template->nodes, preCallback: function ($node) use (&$dataStack) { if ($node instanceof ComponentNode && $node->children) { @@ -137,7 +135,7 @@ public function compileForFolding(string $source, ?string $path = null): string $currentUnblazeToken = null; - $ast = $this->walker->walk( + $ast = Walker::walk( nodes: $template->nodes, preCallback: function (Node $node) use (&$currentUnblazeToken) { if ($node instanceof DirectiveNode && $node->name === 'unblaze') { @@ -191,7 +189,7 @@ public function compileForUnblaze(string $source): string { $template = $this->parser->parse($source); - $ast = $this->walker->walk( + $ast = Walker::walk( nodes: $template->nodes, preCallback: fn ($node) => $node, postCallback: function ($node) { @@ -223,7 +221,7 @@ public function compileForDebug(string $source, ?string $path = null): string { $template = $this->parser->parse($source, $path); - $ast = $this->walker->walk( + $ast = Walker::walk( nodes: $template->nodes, preCallback: fn ($node) => $node, postCallback: function ($node) { diff --git a/src/Compiler/Wrapper.php b/src/Compiler/Wrapper.php index ebbd6377..2ed13a81 100644 --- a/src/Compiler/Wrapper.php +++ b/src/Compiler/Wrapper.php @@ -40,7 +40,7 @@ public function wrap(array $ast, string $path): array $sourceUsesThis = false; $imports = ''; - $ast = (new Walker)->walk( + $ast = Walker::walk( nodes: $ast, preCallback: function ($node) use (&$sourceUsesThis) { if (! $sourceUsesThis && $node->usesVariable('$this') || $node->isDirective(['entangle', 'script', 'assets'])) { @@ -121,7 +121,7 @@ protected function globalVariables(array $ast): string $hasEchoHandlers = $this->blade->hasEchoHandlers(); - foreach ((new Walker)->iterate($ast) as $node) { + foreach (Walker::iterate($ast) as $node) { if (! isset($variables['$app']) && $node->usesVariable('$app')) { $variables['$app'] = '$app = $__blaze->app;'; } diff --git a/src/Parser/Template.php b/src/Parser/Template.php index 60e58796..08c1866f 100644 --- a/src/Parser/Template.php +++ b/src/Parser/Template.php @@ -13,9 +13,9 @@ public function __construct( public array $nodes, ) { $this->directives = new Directives( - (new Walker)->filter($nodes, function (Node $node) { + Walker::filter($nodes, function (Node $node) { return $node->isDirective(['blaze', 'aware', 'props']); }) ); } -} \ No newline at end of file +} diff --git a/src/Parser/Walker.php b/src/Parser/Walker.php index 9ca0cb69..93b7a608 100644 --- a/src/Parser/Walker.php +++ b/src/Parser/Walker.php @@ -13,7 +13,7 @@ class Walker /** * Walk the AST, applying pre-callback before children and post-callback after. */ - public function walk(array $nodes, callable $preCallback, callable $postCallback): array + public static function walk(array $nodes, callable $preCallback, callable $postCallback): array { $result = []; @@ -21,7 +21,7 @@ public function walk(array $nodes, callable $preCallback, callable $postCallback $node = $preCallback($node) ?? $node; if (($node instanceof ComponentNode || $node instanceof SlotNode) && !empty($node->children)) { - $node->children = $this->walk($node->children, $preCallback, $postCallback); + $node->children = self::walk($node->children, $preCallback, $postCallback); } $node = $postCallback($node) ?? $node; @@ -35,21 +35,21 @@ public function walk(array $nodes, callable $preCallback, callable $postCallback /** * @return \Generator */ - public function iterate(array $nodes): \Generator + public static function iterate(array $nodes): \Generator { foreach ($nodes as $node) { yield spl_object_id($node) => $node; if (($node instanceof ComponentNode || $node instanceof SlotNode) && $node->children) { - yield from $this->iterate($node->children); + yield from self::iterate($node->children); } } } - public function filter(array $nodes, callable $predicate): array + public static function filter(array $nodes, callable $predicate): array { return iterator_to_array((function () use ($nodes, $predicate) { - foreach ($this->iterate($nodes) as $key => $value) { + foreach (self::iterate($nodes) as $key => $value) { if ($predicate($value)) { yield $key => $value; } diff --git a/src/Runtime/BlazeRuntime.php b/src/Runtime/BlazeRuntime.php index d6d37094..f8aea6ce 100644 --- a/src/Runtime/BlazeRuntime.php +++ b/src/Runtime/BlazeRuntime.php @@ -16,7 +16,6 @@ use Livewire\Blaze\Parser\Parser; use Livewire\Blaze\Support\AttributeParser; use Livewire\Blaze\Parser\Tokenizer; -use Livewire\Blaze\Parser\Walker; use Livewire\Blaze\BlazeManager; use Livewire\Blaze\Support\ComponentRepository; use Livewire\Blaze\Support\ComponentSource; diff --git a/tests/Parser/ParserTest.php b/tests/Parser/ParserTest.php index 8ec4527c..035f9d4e 100644 --- a/tests/Parser/ParserTest.php +++ b/tests/Parser/ParserTest.php @@ -8,7 +8,6 @@ use Livewire\Blaze\Parser\Nodes\TextNode; use Livewire\Blaze\Parser\Nodes\VerbatimBlockNode; use Livewire\Blaze\Support\AttributeParser; -use Livewire\Blaze\Parser\Walker; test('parses self-closing components', function () { From 9e5ba5156d3ab1166e31a564d70ff21945dace1a Mon Sep 17 00:00:00 2001 From: Filip Ganyicz Date: Fri, 7 Aug 2026 22:33:52 +0200 Subject: [PATCH 23/56] Refactor --- src/BlazeManager.php | 6 +++--- src/Support/ComponentRepository.php | 6 +----- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/src/BlazeManager.php b/src/BlazeManager.php index d57efe66..145e78f2 100644 --- a/src/BlazeManager.php +++ b/src/BlazeManager.php @@ -37,24 +37,24 @@ class BlazeManager protected $foldedEvents = []; protected $expiredMemo = []; - public readonly Parser $parser; - + protected Parser $parser; protected Compiler $compiler; protected Folder $folder; protected Memoizer $memoizer; protected Wrapper $wrapper; protected Profiler $instrumenter; protected BladeRenderer $renderer; + protected ComponentRepository $components; public function __construct( protected Config $config, protected BladeCompiler $bladeCompiler, protected BlazeRuntime $runtime, protected BladeService $blade, - protected ComponentRepository $components, ) { $this->renderer = new BladeRenderer($bladeCompiler, app('view'), $this->runtime, $this); $this->parser = new Parser(new Tokenizer($this->blade), new AttributeParser($this->blade)); + $this->components = new ComponentRepository($this->blade, $this->parser); $this->compiler = new Compiler($config, $this->blade, $this, $this->components); $this->folder = new Folder($config, $this->blade, $this->renderer, $this, $this->components); $this->memoizer = new Memoizer($config, $this->compiler, $this->blade, $this, $this->components); diff --git a/src/Support/ComponentRepository.php b/src/Support/ComponentRepository.php index 2bf6d80b..3422c117 100644 --- a/src/Support/ComponentRepository.php +++ b/src/Support/ComponentRepository.php @@ -4,19 +4,15 @@ use Livewire\Blaze\BladeService; use Livewire\Blaze\Parser\Parser; -use Livewire\Blaze\Parser\Tokenizer; -use Livewire\Blaze\Support\AttributeParser; class ComponentRepository { protected array $components = []; - protected Parser $parser; - public function __construct( protected BladeService $blade, + protected Parser $parser, ) { - $this->parser = new Parser(new Tokenizer($blade), new AttributeParser($blade)); } public function get(string $name): ?ComponentSource From 25a096f7a4ebb0b76f508994384180404e3243e2 Mon Sep 17 00:00:00 2001 From: Filip Ganyicz Date: Fri, 7 Aug 2026 22:34:47 +0200 Subject: [PATCH 24/56] Formatting --- src/BlazeManager.php | 2 -- src/Runtime/BlazeRuntime.php | 8 -------- 2 files changed, 10 deletions(-) diff --git a/src/BlazeManager.php b/src/BlazeManager.php index 145e78f2..2ad25ed4 100644 --- a/src/BlazeManager.php +++ b/src/BlazeManager.php @@ -21,8 +21,6 @@ use Livewire\Blaze\Parser\Nodes\SlotNode; use Livewire\Blaze\Support\AttributeParser; use Livewire\Blaze\Support\ComponentRepository; -use Livewire\Blaze\Support\DirectiveStack; -use Livewire\Blaze\Support\DirectiveStructure; use Livewire\Blaze\Parser\Nodes\Node; use Livewire\Blaze\Parser\Nodes\TextNode; diff --git a/src/Runtime/BlazeRuntime.php b/src/Runtime/BlazeRuntime.php index f8aea6ce..cafc659d 100644 --- a/src/Runtime/BlazeRuntime.php +++ b/src/Runtime/BlazeRuntime.php @@ -7,16 +7,8 @@ use Illuminate\Support\Str; use Illuminate\Support\ViewErrorBag; use Illuminate\View\Compilers\BladeCompiler; -use Illuminate\View\Compilers\Compiler; use Livewire\Blaze\BladeService; -use Livewire\Blaze\Support\Directives; -use Livewire\Blaze\Support\Utils; use Livewire\Blaze\Debugger; -use Livewire\Blaze\Parser\Nodes\DirectiveNode; -use Livewire\Blaze\Parser\Parser; -use Livewire\Blaze\Support\AttributeParser; -use Livewire\Blaze\Parser\Tokenizer; -use Livewire\Blaze\BlazeManager; use Livewire\Blaze\Support\ComponentRepository; use Livewire\Blaze\Support\ComponentSource; From e70dc6e026a182e3cf35f716e453e91af735bef3 Mon Sep 17 00:00:00 2001 From: Filip Ganyicz Date: Fri, 7 Aug 2026 22:38:34 +0200 Subject: [PATCH 25/56] Reset unblaze after replacing --- src/Unblaze.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/Unblaze.php b/src/Unblaze.php index 0b2ccd31..c704c593 100644 --- a/src/Unblaze.php +++ b/src/Unblaze.php @@ -74,6 +74,9 @@ public static function replaceUnblazePrecompiledDirectives(string $template) }, $template); } + static::$unblazeScopes = []; + static::$unblazeReplacements = []; + return $template; } From be4777db089bcf9d343f362b2456756957a1858d Mon Sep 17 00:00:00 2001 From: Filip Ganyicz Date: Fri, 7 Aug 2026 22:59:23 +0200 Subject: [PATCH 26/56] Fix --- src/Compiler/Wrapper.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Compiler/Wrapper.php b/src/Compiler/Wrapper.php index 2ed13a81..15dba2b2 100644 --- a/src/Compiler/Wrapper.php +++ b/src/Compiler/Wrapper.php @@ -43,7 +43,7 @@ public function wrap(array $ast, string $path): array $ast = Walker::walk( nodes: $ast, preCallback: function ($node) use (&$sourceUsesThis) { - if (! $sourceUsesThis && $node->usesVariable('$this') || $node->isDirective(['entangle', 'script', 'assets'])) { + if (! $sourceUsesThis && ($node->usesVariable('$this') || $node->isDirective(['entangle', 'script', 'assets']))) { $sourceUsesThis = true; } From 3f5ecbc0153d66e6be7dece977131efb98925598 Mon Sep 17 00:00:00 2001 From: Filip Ganyicz Date: Fri, 7 Aug 2026 23:02:45 +0200 Subject: [PATCH 27/56] Formatting --- src/Compiler/Wrapper.php | 23 +++++------------------ 1 file changed, 5 insertions(+), 18 deletions(-) diff --git a/src/Compiler/Wrapper.php b/src/Compiler/Wrapper.php index 15dba2b2..b14c572f 100644 --- a/src/Compiler/Wrapper.php +++ b/src/Compiler/Wrapper.php @@ -47,7 +47,7 @@ public function wrap(array $ast, string $path): array $sourceUsesThis = true; } - if ($node instanceof DirectiveNode && $node->name === 'use') { + if ($node instanceof DirectiveNode && $node->name === 'use') { // TODO: we should use ->is('use') for directives to cover for case insensitivty return new PhpBlockNode($this->blade->compileUseStatements($node->expression)); } @@ -74,17 +74,11 @@ public function wrap(array $ast, string $path): array } ); - $opening = ''; - - $opening .= '<'.'?php' . "\n"; + $opening = '<'.'?php' . "\n"; $opening .= $imports; $opening .= 'if (!function_exists(\''.$name.'\')):'."\n"; $opening .= 'function '.$name.'($__blaze, $__data = [], $__slots = [], $__bound = [], $__keys = [], $__this = null) {'."\n"; - - if ($sourceUsesThis) { - $opening .= '$__blazeFn = function () use ($__blaze, $__data, $__slots, $__bound, $__keys) {'."\n"; - } - + $opening .= $sourceUsesThis ? '$__blazeFn = function () use ($__blaze, $__data, $__slots, $__bound, $__keys) {'."\n" : ''; $opening .= $this->globalVariables($ast)."\n"; $opening .= 'if (($__data[\'attributes\'] ?? null) instanceof \Illuminate\View\ComponentAttributeBag) { $__data = $__data + $__data[\'attributes\']->all(); unset($__data[\'attributes\']); }'."\n"; $opening .= 'extract($__slots, EXTR_SKIP); unset($__slots);'."\n"; @@ -95,15 +89,8 @@ public function wrap(array $ast, string $path): array $opening .= '?>' . "\n"; $closing = 'manager->isFolding() ? '$__blaze->processPassthroughContent(\'ltrim\', ltrim(ob_get_clean()))' : 'ltrim(ob_get_clean())'; - - $closing .= 'echo ' . $contentHandler . ';' . "\n"; - - if ($sourceUsesThis) { - $closing .= '}; if ($__this !== null) { $__blazeFn->call($__this); } else { $__blazeFn(); }'."\n"; - } - + $closing .= 'echo ' . ($this->manager->isFolding() ? '$__blaze->processPassthroughContent(\'ltrim\', ltrim(ob_get_clean()))' : 'ltrim(ob_get_clean())') . ';' . "\n"; + $closing .= $sourceUsesThis ? '}; if ($__this !== null) { $__blazeFn->call($__this); } else { $__blazeFn(); }'."\n" : ''; $closing .= '} endif; ?>'; return [ From c2a32ccda251260faebaf975b4eff8b75c643605 Mon Sep 17 00:00:00 2001 From: Filip Ganyicz Date: Fri, 7 Aug 2026 23:07:01 +0200 Subject: [PATCH 28/56] Formatting --- src/Compiler/Wrapper.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Compiler/Wrapper.php b/src/Compiler/Wrapper.php index b14c572f..1e63f5a0 100644 --- a/src/Compiler/Wrapper.php +++ b/src/Compiler/Wrapper.php @@ -74,7 +74,7 @@ public function wrap(array $ast, string $path): array } ); - $opening = '<'.'?php' . "\n"; + $opening = '<'.'?php'."\n"; $opening .= $imports; $opening .= 'if (!function_exists(\''.$name.'\')):'."\n"; $opening .= 'function '.$name.'($__blaze, $__data = [], $__slots = [], $__bound = [], $__keys = [], $__this = null) {'."\n"; @@ -88,8 +88,8 @@ public function wrap(array $ast, string $path): array $opening .= 'ob_start();' . "\n"; $opening .= '?>' . "\n"; - $closing = 'manager->isFolding() ? '$__blaze->processPassthroughContent(\'ltrim\', ltrim(ob_get_clean()))' : 'ltrim(ob_get_clean())') . ';' . "\n"; + $closing = 'manager->isFolding() ? '$__blaze->processPassthroughContent(\'ltrim\', ltrim(ob_get_clean()));' : 'ltrim(ob_get_clean());')."\n"; $closing .= $sourceUsesThis ? '}; if ($__this !== null) { $__blazeFn->call($__this); } else { $__blazeFn(); }'."\n" : ''; $closing .= '} endif; ?>'; @@ -129,7 +129,7 @@ protected function globalVariables(array $ast): string $variables['$slot'] = '$__slots[\'slot\'] ??= new \Illuminate\View\ComponentSlot(\'\');'; } - if ($hasEchoHandlers && ! isset($variables['$__bladeCompiler']) && $node->usesEchoSyntax()) { + if (! isset($variables['$__bladeCompiler']) && $hasEchoHandlers && $node->usesEchoSyntax()) { $variables['$__bladeCompiler'] = '$__bladeCompiler = app(\'blade.compiler\');'; } } From 7de0cd78036f7a113567e746256c23427a3bf350 Mon Sep 17 00:00:00 2001 From: Filip Ganyicz Date: Fri, 7 Aug 2026 23:15:53 +0200 Subject: [PATCH 29/56] Formatting --- src/BlazeManager.php | 4 +++- src/Parser/Parser.php | 1 - 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/BlazeManager.php b/src/BlazeManager.php index 2ad25ed4..f381442d 100644 --- a/src/BlazeManager.php +++ b/src/BlazeManager.php @@ -23,6 +23,7 @@ use Livewire\Blaze\Support\ComponentRepository; use Livewire\Blaze\Parser\Nodes\Node; use Livewire\Blaze\Parser\Nodes\TextNode; +use Illuminate\View\Factory; class BlazeManager { @@ -49,8 +50,9 @@ public function __construct( protected BladeCompiler $bladeCompiler, protected BlazeRuntime $runtime, protected BladeService $blade, + protected Factory $factory, ) { - $this->renderer = new BladeRenderer($bladeCompiler, app('view'), $this->runtime, $this); + $this->renderer = new BladeRenderer($bladeCompiler, $factory, $this->runtime, $this); $this->parser = new Parser(new Tokenizer($this->blade), new AttributeParser($this->blade)); $this->components = new ComponentRepository($this->blade, $this->parser); $this->compiler = new Compiler($config, $this->blade, $this, $this->components); diff --git a/src/Parser/Parser.php b/src/Parser/Parser.php index 1692f96e..f1dae43f 100644 --- a/src/Parser/Parser.php +++ b/src/Parser/Parser.php @@ -2,7 +2,6 @@ namespace Livewire\Blaze\Parser; -use Livewire\Blaze\BladeService; use Livewire\Blaze\Parser\Nodes\ComponentNode; use Livewire\Blaze\Parser\Nodes\DirectiveNode; use Livewire\Blaze\Parser\Nodes\PhpBlockNode; From b2293c3ecfb85fb1e0074873e9d1665ff442f58d Mon Sep 17 00:00:00 2001 From: Filip Ganyicz Date: Fri, 7 Aug 2026 23:22:57 +0200 Subject: [PATCH 30/56] Revert "Reset unblaze after replacing" This reverts commit e70dc6e026a182e3cf35f716e453e91af735bef3. --- src/Unblaze.php | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/Unblaze.php b/src/Unblaze.php index c704c593..0b2ccd31 100644 --- a/src/Unblaze.php +++ b/src/Unblaze.php @@ -74,9 +74,6 @@ public static function replaceUnblazePrecompiledDirectives(string $template) }, $template); } - static::$unblazeScopes = []; - static::$unblazeReplacements = []; - return $template; } From f7a735f377addb1a10dc5f8bbc41e4eab9a0cc28 Mon Sep 17 00:00:00 2001 From: Filip Ganyicz Date: Fri, 7 Aug 2026 23:28:20 +0200 Subject: [PATCH 31/56] Rename --- src/Parser/Parser.php | 14 ++++----- src/Parser/Tokenizer.php | 8 ++--- ...{ClosingTagToken.php => TagCloseToken.php} | 2 +- .../{OpeningTagToken.php => TagOpenToken.php} | 2 +- tests/Parser/TokenizerTest.php | 30 +++++++++---------- 5 files changed, 28 insertions(+), 28 deletions(-) rename src/Parser/Tokens/{ClosingTagToken.php => TagCloseToken.php} (87%) rename src/Parser/Tokens/{OpeningTagToken.php => TagOpenToken.php} (94%) diff --git a/src/Parser/Parser.php b/src/Parser/Parser.php index f1dae43f..c107568d 100644 --- a/src/Parser/Parser.php +++ b/src/Parser/Parser.php @@ -8,9 +8,9 @@ use Livewire\Blaze\Parser\Nodes\SlotNode; use Livewire\Blaze\Parser\Nodes\TextNode; use Livewire\Blaze\Parser\Nodes\VerbatimBlockNode; -use Livewire\Blaze\Parser\Tokens\ClosingTagToken; +use Livewire\Blaze\Parser\Tokens\TagCloseToken; use Livewire\Blaze\Parser\Tokens\DirectiveToken; -use Livewire\Blaze\Parser\Tokens\OpeningTagToken; +use Livewire\Blaze\Parser\Tokens\TagOpenToken; use Livewire\Blaze\Parser\Tokens\PhpBlockToken; use Livewire\Blaze\Parser\Tokens\TextToken; use Livewire\Blaze\Parser\Tokens\VerbatimBlockToken; @@ -44,8 +44,8 @@ public function parse(string $content, ?string $path = null): Template foreach ($tokens as $token) { match(get_class($token)) { - OpeningTagToken::class => $this->handleOpeningTag($token, $stack), - ClosingTagToken::class => $this->handleClosingTag($token, $stack), + TagOpenToken::class => $this->handleOpeningTag($token, $stack), + TagCloseToken::class => $this->handleClosingTag($token, $stack), DirectiveToken::class => $this->handleDirective($token, $stack), TextToken::class => $this->handleText($token, $stack), PhpBlockToken::class => $this->handlePhpBlock($token, $stack), @@ -66,7 +66,7 @@ public function parse(string $content, ?string $path = null): Template /** * Handle an opening component tag token. */ - protected function handleOpeningTag(OpeningTagToken $token, ParseStack $stack): void + protected function handleOpeningTag(TagOpenToken $token, ParseStack $stack): void { if ($token->isSlot()) { $this->handleSlotOpen($token, $stack); @@ -93,7 +93,7 @@ protected function handleOpeningTag(OpeningTagToken $token, ParseStack $stack): /** * Handle a closing component or slot tag token. */ - protected function handleClosingTag(ClosingTagToken $token, ParseStack $stack): void + protected function handleClosingTag(TagCloseToken $token, ParseStack $stack): void { $closed = $stack->popContainer(); @@ -105,7 +105,7 @@ protected function handleClosingTag(ClosingTagToken $token, ParseStack $stack): /** * Handle an opening slot tag token. */ - protected function handleSlotOpen(OpeningTagToken $token, ParseStack $stack): void + protected function handleSlotOpen(TagOpenToken $token, ParseStack $stack): void { $short = str_starts_with($token->name, 'slot:'); diff --git a/src/Parser/Tokenizer.php b/src/Parser/Tokenizer.php index 7a5bd904..ebbc71f4 100644 --- a/src/Parser/Tokenizer.php +++ b/src/Parser/Tokenizer.php @@ -4,9 +4,9 @@ use Illuminate\Support\Str; use Livewire\Blaze\BladeService; -use Livewire\Blaze\Parser\Tokens\ClosingTagToken; +use Livewire\Blaze\Parser\Tokens\TagCloseToken; use Livewire\Blaze\Parser\Tokens\DirectiveToken; -use Livewire\Blaze\Parser\Tokens\OpeningTagToken; +use Livewire\Blaze\Parser\Tokens\TagOpenToken; use Livewire\Blaze\Parser\Tokens\PhpBlockToken; use Livewire\Blaze\Parser\Tokens\TextToken; use Livewire\Blaze\Parser\Tokens\Token; @@ -134,7 +134,7 @@ protected function process(): void $this->advance(strlen($match['original'])); - $this->emitToken(new OpeningTagToken($match['prefix'], $match['name'], $match['attributes'], $match['original'], $match['selfClosing'])); + $this->emitToken(new TagOpenToken($match['prefix'], $match['name'], $match['attributes'], $match['original'], $match['selfClosing'])); return; } @@ -144,7 +144,7 @@ protected function process(): void $this->advance(strlen($match['original'])); - $this->emitToken(new ClosingTagToken($match['prefix'], $match['name'], $match['original'])); + $this->emitToken(new TagCloseToken($match['prefix'], $match['name'], $match['original'])); return; } diff --git a/src/Parser/Tokens/ClosingTagToken.php b/src/Parser/Tokens/TagCloseToken.php similarity index 87% rename from src/Parser/Tokens/ClosingTagToken.php rename to src/Parser/Tokens/TagCloseToken.php index 7fa62baa..5ba08e23 100644 --- a/src/Parser/Tokens/ClosingTagToken.php +++ b/src/Parser/Tokens/TagCloseToken.php @@ -5,7 +5,7 @@ /** * Represents a closing component tag (). */ -class ClosingTagToken extends Token +class TagCloseToken extends Token { public function __construct( public string $prefix, diff --git a/src/Parser/Tokens/OpeningTagToken.php b/src/Parser/Tokens/TagOpenToken.php similarity index 94% rename from src/Parser/Tokens/OpeningTagToken.php rename to src/Parser/Tokens/TagOpenToken.php index 78c20cdd..cdd4c10f 100644 --- a/src/Parser/Tokens/OpeningTagToken.php +++ b/src/Parser/Tokens/TagOpenToken.php @@ -5,7 +5,7 @@ /** * Represents an opening or self-closing component tag. */ -class OpeningTagToken extends Token +class TagOpenToken extends Token { public function __construct( public string $prefix, diff --git a/tests/Parser/TokenizerTest.php b/tests/Parser/TokenizerTest.php index e1707e63..867c1bc9 100644 --- a/tests/Parser/TokenizerTest.php +++ b/tests/Parser/TokenizerTest.php @@ -1,10 +1,10 @@ tokenize($input); expect($result)->toEqual([ - new OpeningTagToken(prefix: 'x-', name: 'button', attributes: ' type="button"', original: '', selfClosing: false), - new ClosingTagToken(prefix: 'x-', name: 'button', original: ''), + new TagOpenToken(prefix: 'x-', name: 'button', attributes: ' type="button"', original: '', selfClosing: false), + new TagCloseToken(prefix: 'x-', name: 'button', original: ''), ]); }); @@ -34,7 +34,7 @@ $result = app(Tokenizer::class)->tokenize($input); expect($result)->toEqual([ - new OpeningTagToken(prefix: 'x-', name: 'button', attributes: ' type="button" ', original: '', selfClosing: true), + new TagOpenToken(prefix: 'x-', name: 'button', attributes: ' type="button" ', original: '', selfClosing: true), ]); }); @@ -44,8 +44,8 @@ $result = app(Tokenizer::class)->tokenize($input); expect($result)->toEqual([ - new OpeningTagToken(prefix: 'flux:', name: 'button', attributes: ' type="button"', original: '', selfClosing: false), - new ClosingTagToken(prefix: 'flux:', name: 'button', original: ''), + new TagOpenToken(prefix: 'flux:', name: 'button', attributes: ' type="button"', original: '', selfClosing: false), + new TagCloseToken(prefix: 'flux:', name: 'button', original: ''), ]); }); @@ -54,9 +54,9 @@ expect(app(Tokenizer::class)->tokenize($input))->toEqual([ new TextToken('< invalid '), - new OpeningTagToken(prefix: 'x-', name: 'button', attributes: '', original: '', selfClosing: false), + new TagOpenToken(prefix: 'x-', name: 'button', attributes: '', original: '', selfClosing: false), new TextToken(''), + new TagCloseToken(prefix: 'x-', name: 'button', original: ''), ]); }); @@ -86,9 +86,9 @@ $result = app(Tokenizer::class)->tokenize($input); expect($result)->toEqual([ - new OpeningTagToken(prefix: 'x-', name: 'button', attributes: '', original: '', selfClosing: false), + new TagOpenToken(prefix: 'x-', name: 'button', attributes: '', original: '', selfClosing: false), new PhpBlockToken(content: ' ?>'), - new ClosingTagToken(prefix: 'x-', name: 'button', original: ''), + new TagCloseToken(prefix: 'x-', name: 'button', original: ''), ]); }); @@ -106,11 +106,11 @@ $input = ' @php $value = ""; @endphp '; expect(app(Tokenizer::class)->tokenize($input))->toEqual([ - new OpeningTagToken(prefix: 'x-', name: 'button', attributes: '', original: '', selfClosing: false), + new TagOpenToken(prefix: 'x-', name: 'button', attributes: '', original: '', selfClosing: false), new TextToken(' '), new PhpBlockToken(content: '@php $value = ""; @endphp'), new TextToken(' '), - new ClosingTagToken(prefix: 'x-', name: 'button', original: ''), + new TagCloseToken(prefix: 'x-', name: 'button', original: ''), ]); }); @@ -120,7 +120,7 @@ expect(app(Tokenizer::class)->tokenize($input))->toEqual([ new DirectiveToken(name: 'php', original: '@php '), // <-- TODO: weird whitespace new TextToken(content: '$value = "'), - new OpeningTagToken( + new TagOpenToken( prefix: 'x-', name: 'button', attributes: ' ', // <-- TODO: weird whitespace original: '', @@ -278,6 +278,6 @@ $result = app(Tokenizer::class)->tokenize($input); expect($result)->toEqual([ - new OpeningTagToken(prefix: 'x-', name: 'button', attributes: ' ', original: '', selfClosing: true), + new TagOpenToken(prefix: 'x-', name: 'button', attributes: ' ', original: '', selfClosing: true), ]); }); \ No newline at end of file From 5834fbba4598bc91c420d62e6ebea74eebd00160 Mon Sep 17 00:00:00 2001 From: Filip Ganyicz Date: Fri, 7 Aug 2026 23:48:04 +0200 Subject: [PATCH 32/56] Add todos --- src/Support/Directives.php | 2 ++ src/Support/LaravelRegex.php | 2 ++ 2 files changed, 4 insertions(+) diff --git a/src/Support/Directives.php b/src/Support/Directives.php index e3b4114d..b209bdab 100644 --- a/src/Support/Directives.php +++ b/src/Support/Directives.php @@ -7,6 +7,8 @@ /** * Extracts and queries Blade directives from component source content. + * + * TODO: make this less flexible - we only need props, aware and blaze */ class Directives { diff --git a/src/Support/LaravelRegex.php b/src/Support/LaravelRegex.php index d15bf61a..2db20538 100644 --- a/src/Support/LaravelRegex.php +++ b/src/Support/LaravelRegex.php @@ -13,6 +13,8 @@ * @see vendor/laravel/framework/src/Illuminate/View/Compilers/ComponentTagCompiler.php * @see vendor/laravel/framework/src/Illuminate/View/Compilers/BladeCompiler.php * @see vendor/laravel/framework/src/Illuminate/View/Compilers/Concerns/CompilesComments.php + * + * TODO: check if we use all of these */ class LaravelRegex { From 67fb5e0d97ed14419c4ccc5bcef9aaf5da13ee80 Mon Sep 17 00:00:00 2001 From: Filip Ganyicz Date: Sat, 8 Aug 2026 04:08:38 +0200 Subject: [PATCH 33/56] Update BlazeServiceProvider.php --- src/BlazeServiceProvider.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/BlazeServiceProvider.php b/src/BlazeServiceProvider.php index b05ada43..2bc23bb0 100644 --- a/src/BlazeServiceProvider.php +++ b/src/BlazeServiceProvider.php @@ -26,7 +26,6 @@ public function register(): void $this->app->singleton(Debugger::class); $this->app->singleton(Profiler::class); $this->app->singleton(BlazeManager::class); - $this->app->singleton(ComponentRepository::class); $this->app->singleton(\PhpParser\Parser::class, function () { return (new \PhpParser\ParserFactory)->createForNewestSupportedVersion(); From be10c91b755704d9db763c9383baa4f92b41cb96 Mon Sep 17 00:00:00 2001 From: Filip Ganyicz Date: Sat, 8 Aug 2026 17:31:11 +0200 Subject: [PATCH 34/56] Add todos --- src/BlazeManager.php | 5 +++++ src/Unblaze.php | 1 + 2 files changed, 6 insertions(+) diff --git a/src/BlazeManager.php b/src/BlazeManager.php index f381442d..2d531734 100644 --- a/src/BlazeManager.php +++ b/src/BlazeManager.php @@ -410,6 +410,11 @@ protected function hasAwareDescendant(ComponentNode|SlotNode $node): bool return true; } + // TODO: Not having this condition threw for class-based component like , that should be tested somehow + if (! $component) { + return false; + } + if ($component->template->directives->has('aware')) { return true; } diff --git a/src/Unblaze.php b/src/Unblaze.php index 0b2ccd31..0ff18fe6 100644 --- a/src/Unblaze.php +++ b/src/Unblaze.php @@ -10,6 +10,7 @@ */ class Unblaze { + // TODO: these should be cleared at some point during rendering static $unblazeScopes = []; static $unblazeReplacements = []; From 43e5d316d4e4209cc8f9dbc508c348a9fb1df514 Mon Sep 17 00:00:00 2001 From: Filip Ganyicz Date: Sun, 9 Aug 2026 21:52:09 +0200 Subject: [PATCH 35/56] Formatting --- src/BladeRenderer.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/BladeRenderer.php b/src/BladeRenderer.php index 0e552c19..aab4b189 100644 --- a/src/BladeRenderer.php +++ b/src/BladeRenderer.php @@ -11,9 +11,9 @@ use Livewire\Blaze\Parser\Nodes\ComponentNode; use Livewire\Blaze\Parser\Nodes\SlotNode; use Livewire\Blaze\Runtime\BlazeRuntime; +use Livewire\Blaze\Support\ComponentSource; use Livewire\Blaze\Support\Utils; use ReflectionClass; -use Livewire\Blaze\Support\ComponentSource; /** * Handles isolated Blade rendering used during compile-time folding. From 46e2092f5c9029dfbbc77b419fa2a97c10a02fee Mon Sep 17 00:00:00 2001 From: Filip Ganyicz Date: Sun, 9 Aug 2026 22:01:05 +0200 Subject: [PATCH 36/56] Remove todo --- src/Support/Directives.php | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/Support/Directives.php b/src/Support/Directives.php index b209bdab..e3b4114d 100644 --- a/src/Support/Directives.php +++ b/src/Support/Directives.php @@ -7,8 +7,6 @@ /** * Extracts and queries Blade directives from component source content. - * - * TODO: make this less flexible - we only need props, aware and blaze */ class Directives { From baea97eb60d136761acdcd349346b36014d09e27 Mon Sep 17 00:00:00 2001 From: Filip Ganyicz Date: Sun, 9 Aug 2026 22:05:08 +0200 Subject: [PATCH 37/56] Refactor --- src/BlazeManager.php | 4 ++-- src/Compiler/Wrapper.php | 6 +++--- src/Parser/Nodes/DirectiveNode.php | 5 +++++ 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/BlazeManager.php b/src/BlazeManager.php index 2d531734..0492f038 100644 --- a/src/BlazeManager.php +++ b/src/BlazeManager.php @@ -138,7 +138,7 @@ public function compileForFolding(string $source, ?string $path = null): string $ast = Walker::walk( nodes: $template->nodes, preCallback: function (Node $node) use (&$currentUnblazeToken) { - if ($node instanceof DirectiveNode && $node->name === 'unblaze') { + if ($node instanceof DirectiveNode && $node->is('unblaze')) { $currentUnblazeToken = str()->random(10); $tag = '[STARTCOMPILEDUNBLAZE:' . $currentUnblazeToken . ']'; $content = 'expression . '); ?>'; @@ -146,7 +146,7 @@ public function compileForFolding(string $source, ?string $path = null): string return new TextNode($tag . $content); } - if ($node instanceof DirectiveNode && $node->name === 'endunblaze' && $currentUnblazeToken) { + if ($node instanceof DirectiveNode && $node->is('endunblaze') && $currentUnblazeToken) { $tag = '[ENDCOMPILEDUNBLAZE:' . $currentUnblazeToken . ']'; $currentUnblazeToken = null; diff --git a/src/Compiler/Wrapper.php b/src/Compiler/Wrapper.php index 1e63f5a0..f43804df 100644 --- a/src/Compiler/Wrapper.php +++ b/src/Compiler/Wrapper.php @@ -47,7 +47,7 @@ public function wrap(array $ast, string $path): array $sourceUsesThis = true; } - if ($node instanceof DirectiveNode && $node->name === 'use') { // TODO: we should use ->is('use') for directives to cover for case insensitivty + if ($node instanceof DirectiveNode && $node->is('use')) { return new PhpBlockNode($this->blade->compileUseStatements($node->expression)); } @@ -62,11 +62,11 @@ public function wrap(array $ast, string $path): array ); } - if ($node instanceof DirectiveNode && $node->name === 'props') { + if ($node instanceof DirectiveNode && $node->is('props')) { return new PhpBlockNode($this->propsCompiler->compile($node->expression)); } - if ($node instanceof DirectiveNode && $node->name === 'aware') { + if ($node instanceof DirectiveNode && $node->is('aware')) { return new PhpBlockNode($this->awareCompiler->compile($node->expression)); } diff --git a/src/Parser/Nodes/DirectiveNode.php b/src/Parser/Nodes/DirectiveNode.php index 9711c3ca..aecfe2f2 100644 --- a/src/Parser/Nodes/DirectiveNode.php +++ b/src/Parser/Nodes/DirectiveNode.php @@ -15,4 +15,9 @@ public function render(): string { return $this->original; } + + public function is(string $name): bool + { + return strtolower($this->name) === strtolower($name); + } } From bee1460bb639d1f03f3d14a9d94f481fdbc57dd8 Mon Sep 17 00:00:00 2001 From: Filip Ganyicz Date: Sun, 9 Aug 2026 22:12:09 +0200 Subject: [PATCH 38/56] Remove todo --- src/Unblaze.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Unblaze.php b/src/Unblaze.php index 0ff18fe6..0b2ccd31 100644 --- a/src/Unblaze.php +++ b/src/Unblaze.php @@ -10,7 +10,6 @@ */ class Unblaze { - // TODO: these should be cleared at some point during rendering static $unblazeScopes = []; static $unblazeReplacements = []; From 69f3887c2058d6a1659406d35b354010f2bd31ab Mon Sep 17 00:00:00 2001 From: Filip Ganyicz Date: Sun, 9 Aug 2026 22:14:26 +0200 Subject: [PATCH 39/56] Refactor --- src/Compiler/Wrapper.php | 10 +++--- src/Folder/Folder.php | 70 ++++++++++++++++++++++++++++----------- src/Parser/Nodes/Node.php | 8 ++--- 3 files changed, 59 insertions(+), 29 deletions(-) diff --git a/src/Compiler/Wrapper.php b/src/Compiler/Wrapper.php index f43804df..48936eeb 100644 --- a/src/Compiler/Wrapper.php +++ b/src/Compiler/Wrapper.php @@ -43,7 +43,7 @@ public function wrap(array $ast, string $path): array $ast = Walker::walk( nodes: $ast, preCallback: function ($node) use (&$sourceUsesThis) { - if (! $sourceUsesThis && ($node->usesVariable('$this') || $node->isDirective(['entangle', 'script', 'assets']))) { + if (! $sourceUsesThis && ($node->containsPhp('$this') || $node->isDirective(['entangle', 'script', 'assets']))) { $sourceUsesThis = true; } @@ -109,15 +109,15 @@ protected function globalVariables(array $ast): string $hasEchoHandlers = $this->blade->hasEchoHandlers(); foreach (Walker::iterate($ast) as $node) { - if (! isset($variables['$app']) && $node->usesVariable('$app')) { + if (! isset($variables['$app']) && $node->containsPhp('$app')) { $variables['$app'] = '$app = $__blaze->app;'; } - if (! isset($variables['$errors']) && ($node->usesVariable('$errors') || $node->isDirective('error'))) { + if (! isset($variables['$errors']) && ($node->containsPhp('$errors') || $node->isDirective('error'))) { $variables['$errors'] = '$errors = $__blaze->errors;'; } - if (! isset($variables['$__livewire']) && ($node->usesVariable('$__livewire') || $node->isDirective('entangle') || $node->isDirective('this'))) { + if (! isset($variables['$__livewire']) && ($node->containsPhp('$__livewire') || $node->isDirective('entangle') || $node->isDirective('this'))) { $variables['$__livewire'] = '$__livewire = $__env->shared(\'__livewire\');'; } @@ -125,7 +125,7 @@ protected function globalVariables(array $ast): string $variables['$_instance'] = '$_instance = $__livewire;'; } - if (! isset($variables['$slot']) && $node->usesVariable('$slot')) { + if (! isset($variables['$slot']) && $node->containsPhp('$slot')) { $variables['$slot'] = '$__slots[\'slot\'] ??= new \Illuminate\View\ComponentSlot(\'\');'; } diff --git a/src/Folder/Folder.php b/src/Folder/Folder.php index be312ff6..6cd25b0f 100644 --- a/src/Folder/Folder.php +++ b/src/Folder/Folder.php @@ -16,6 +16,7 @@ use Illuminate\Support\Arr; use Livewire\Blaze\Config; use Livewire\Blaze\Parser\Nodes\DirectiveNode; +use Livewire\Blaze\Parser\Walker; use Livewire\Blaze\Support\DirectiveStack; use Throwable; use Livewire\Blaze\Support\ComponentRepository; @@ -216,26 +217,55 @@ protected function slotHasDynamicAttributes(SlotNode $slot): bool */ protected function checkProblematicPatterns(ComponentSource $source): void { - // TODO: Refactor to AST - $content = file_get_contents($source->path); - - // @unblaze blocks can contain dynamic content and are excluded from validation - $sourceWithoutUnblaze = preg_replace('/@unblaze.*?@endunblaze/s', '', $content); - - $problematicPatterns = [ - '@once' => 'forOnce', - '\\$errors' => 'forErrors', - 'session\\(' => 'forSession', - '@error\\(' => 'forError', - '@csrf' => 'forCsrf', - 'auth\\(\\)' => 'forAuth', - 'request\\(\\)' => 'forRequest', - 'old\\(' => 'forOld', - ]; - - foreach ($problematicPatterns as $pattern => $factoryMethod) { - if (preg_match('/'.$pattern.'/', $sourceWithoutUnblaze)) { - throw InvalidBlazeFoldUsageException::{$factoryMethod}($source->path); + $insideUnblaze = false; + + foreach (Walker::iterate($source->template->nodes) as $node) { + if ($node->isDirective('unblaze')) { + $insideUnblaze = true; + + continue; + } + + if ($node->isDirective('endunblaze')) { + $insideUnblaze = false; + + continue; + } + + if ($insideUnblaze) { + continue; + } + + if ($node->isDirective('once')) { + throw InvalidBlazeFoldUsageException::forOnce($source->path); + } + + if ($node->containsPhp('$errors')) { + throw InvalidBlazeFoldUsageException::forErrors($source->path); + } + + if ($node->containsPhp('session(')) { + throw InvalidBlazeFoldUsageException::forSession($source->path); + } + + if ($node->isDirective('error')) { + throw InvalidBlazeFoldUsageException::forError($source->path); + } + + if ($node->isDirective('csrf')) { + throw InvalidBlazeFoldUsageException::forCsrf($source->path); + } + + if ($node->containsPhp('auth()')) { + throw InvalidBlazeFoldUsageException::forAuth($source->path); + } + + if ($node->containsPhp('request()')) { + throw InvalidBlazeFoldUsageException::forRequest($source->path); + } + + if ($node->containsPhp('old(')) { + throw InvalidBlazeFoldUsageException::forOld($source->path); } } } diff --git a/src/Parser/Nodes/Node.php b/src/Parser/Nodes/Node.php index 86f5701f..7529f448 100644 --- a/src/Parser/Nodes/Node.php +++ b/src/Parser/Nodes/Node.php @@ -12,23 +12,23 @@ abstract class Node */ abstract public function render(): string; - public function usesVariable(string $variable): bool + public function containsPhp(string $php): bool { // TODO: for TextNode we should check variables inside {{ }} if ($this instanceof PhpBlockNode || $this instanceof TextNode) { - if (str_contains($this->content, $variable)) { + if (str_contains($this->content, $php)) { return true; } } if ($this instanceof ComponentNode || $this instanceof SlotNode) { - if (str_contains($this->attributeString, $variable)) { + if (str_contains($this->attributeString, $php)) { return true; } } if ($this instanceof DirectiveNode) { - if (str_contains($this->expression, $variable)) { + if (str_contains($this->expression, $php)) { return true; } } From ce12c147ce4fec88765c1913fd5649cfa6c5e5a1 Mon Sep 17 00:00:00 2001 From: Filip Ganyicz Date: Sun, 9 Aug 2026 22:16:02 +0200 Subject: [PATCH 40/56] Add todo --- src/BladeService.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/BladeService.php b/src/BladeService.php index 6393c2aa..d05fc762 100644 --- a/src/BladeService.php +++ b/src/BladeService.php @@ -10,6 +10,7 @@ use Livewire\Blaze\Parser\Attribute; use ReflectionClass; +// TODO: we should cache the reflection class BladeService { protected ComponentTagCompiler $tagCompiler; From e4e31e3ff6c65369a9fee81f6a07d4c63dc28f74 Mon Sep 17 00:00:00 2001 From: Filip Ganyicz Date: Sun, 9 Aug 2026 22:55:43 +0200 Subject: [PATCH 41/56] Add EchoNode to parser --- src/Compiler/Wrapper.php | 3 ++- src/Parser/Nodes/EchoNode.php | 20 +++++++++++++++++ src/Parser/Nodes/Node.php | 24 +++++---------------- src/Parser/Parser.php | 11 ++++++++++ src/Parser/Tokenizer.php | 38 ++++++++++++++++++++++++++++++++- src/Parser/Tokens/EchoToken.php | 11 ++++++++++ tests/Parser/ParserTest.php | 11 ++++++++++ tests/Parser/TokenizerTest.php | 28 +++++++++++++++++++++++- 8 files changed, 124 insertions(+), 22 deletions(-) create mode 100644 src/Parser/Nodes/EchoNode.php create mode 100644 src/Parser/Tokens/EchoToken.php diff --git a/src/Compiler/Wrapper.php b/src/Compiler/Wrapper.php index 48936eeb..6440778f 100644 --- a/src/Compiler/Wrapper.php +++ b/src/Compiler/Wrapper.php @@ -6,6 +6,7 @@ use Livewire\Blaze\BlazeManager; use Livewire\Blaze\Support\Utils; use Livewire\Blaze\Parser\Nodes\DirectiveNode; +use Livewire\Blaze\Parser\Nodes\EchoNode; use Livewire\Blaze\Parser\Nodes\PhpBlockNode; use Livewire\Blaze\Parser\Walker; use Livewire\Blaze\Compiler\UseExtractor; @@ -129,7 +130,7 @@ protected function globalVariables(array $ast): string $variables['$slot'] = '$__slots[\'slot\'] ??= new \Illuminate\View\ComponentSlot(\'\');'; } - if (! isset($variables['$__bladeCompiler']) && $hasEchoHandlers && $node->usesEchoSyntax()) { + if (! isset($variables['$__bladeCompiler']) && $hasEchoHandlers && $node instanceof EchoNode) { $variables['$__bladeCompiler'] = '$__bladeCompiler = app(\'blade.compiler\');'; } } diff --git a/src/Parser/Nodes/EchoNode.php b/src/Parser/Nodes/EchoNode.php new file mode 100644 index 00000000..b8dab899 --- /dev/null +++ b/src/Parser/Nodes/EchoNode.php @@ -0,0 +1,20 @@ +original; + } +} diff --git a/src/Parser/Nodes/Node.php b/src/Parser/Nodes/Node.php index 7529f448..609f25fb 100644 --- a/src/Parser/Nodes/Node.php +++ b/src/Parser/Nodes/Node.php @@ -14,13 +14,16 @@ abstract public function render(): string; public function containsPhp(string $php): bool { - // TODO: for TextNode we should check variables inside {{ }} - if ($this instanceof PhpBlockNode || $this instanceof TextNode) { + if ($this instanceof PhpBlockNode) { if (str_contains($this->content, $php)) { return true; } } + if ($this instanceof EchoNode) { + return str_contains($this->expression, $php); + } + if ($this instanceof ComponentNode || $this instanceof SlotNode) { if (str_contains($this->attributeString, $php)) { return true; @@ -36,23 +39,6 @@ public function containsPhp(string $php): bool return false; } - public function usesEchoSyntax(): bool - { - if ($this instanceof TextNode) { - if (preg_match('/\{\{.+?\}\}|\{!!.+?!!\}/s', $this->content) === 1) { - return true; - } - } - - if ($this instanceof ComponentNode || $this instanceof SlotNode) { - if (preg_match('/\{\{.+?\}\}|\{!!.+?!!\}/s', $this->attributeString) === 1) { - return true; - } - } - - return false; - } - public function isDirective(string|array $name): bool { $names = is_array($name) ? $name : [$name]; diff --git a/src/Parser/Parser.php b/src/Parser/Parser.php index c107568d..a2385f46 100644 --- a/src/Parser/Parser.php +++ b/src/Parser/Parser.php @@ -4,12 +4,14 @@ use Livewire\Blaze\Parser\Nodes\ComponentNode; use Livewire\Blaze\Parser\Nodes\DirectiveNode; +use Livewire\Blaze\Parser\Nodes\EchoNode; use Livewire\Blaze\Parser\Nodes\PhpBlockNode; use Livewire\Blaze\Parser\Nodes\SlotNode; use Livewire\Blaze\Parser\Nodes\TextNode; use Livewire\Blaze\Parser\Nodes\VerbatimBlockNode; use Livewire\Blaze\Parser\Tokens\TagCloseToken; use Livewire\Blaze\Parser\Tokens\DirectiveToken; +use Livewire\Blaze\Parser\Tokens\EchoToken; use Livewire\Blaze\Parser\Tokens\TagOpenToken; use Livewire\Blaze\Parser\Tokens\PhpBlockToken; use Livewire\Blaze\Parser\Tokens\TextToken; @@ -47,6 +49,7 @@ public function parse(string $content, ?string $path = null): Template TagOpenToken::class => $this->handleOpeningTag($token, $stack), TagCloseToken::class => $this->handleClosingTag($token, $stack), DirectiveToken::class => $this->handleDirective($token, $stack), + EchoToken::class => $this->handleEcho($token, $stack), TextToken::class => $this->handleText($token, $stack), PhpBlockToken::class => $this->handlePhpBlock($token, $stack), VerbatimBlockToken::class => $this->handleVerbatimBlock($token, $stack), @@ -146,6 +149,14 @@ protected function handleDirective(DirectiveToken $token, ParseStack $stack): vo $stack->addToRoot($node); } + protected function handleEcho(EchoToken $token, ParseStack $stack): void + { + $stack->addToRoot(new EchoNode( + expression: $token->expression, + original: $token->original, + )); + } + /** * Handle a text content token. */ diff --git a/src/Parser/Tokenizer.php b/src/Parser/Tokenizer.php index ebbc71f4..f226cd99 100644 --- a/src/Parser/Tokenizer.php +++ b/src/Parser/Tokenizer.php @@ -6,6 +6,7 @@ use Livewire\Blaze\BladeService; use Livewire\Blaze\Parser\Tokens\TagCloseToken; use Livewire\Blaze\Parser\Tokens\DirectiveToken; +use Livewire\Blaze\Parser\Tokens\EchoToken; use Livewire\Blaze\Parser\Tokens\TagOpenToken; use Livewire\Blaze\Parser\Tokens\PhpBlockToken; use Livewire\Blaze\Parser\Tokens\TextToken; @@ -115,6 +116,20 @@ protected function process(): void return; } + if ($this->current() === '{' && $match = $this->matchEcho()) { + if ($this->position > 0 && $this->content[$this->position - 1] === '@') { + $this->advance(strlen($match['original'])); + + return; + } + + $this->flushBuffer(); + $this->advance(strlen($match['original'])); + $this->emitToken(new EchoToken($match['expression'], $match['original'])); + + return; + } + if ($this->current() === '@' && $match = $this->matchDirective()) { $this->flushBuffer(); @@ -149,7 +164,28 @@ protected function process(): void return; } - $this->advanceUntilNext('<@'); + $this->advanceUntilNext('<@{'); + } + + /** + * Match an executable Blade echo at the current position. + */ + protected function matchEcho(): ?array + { + $remaining = $this->remaining(); + + foreach (['/^{!!\s*(.+?)\s*!!}/s', '/^{{{\s*(.+?)\s*}}}/s', '/^{{\s*(.+?)\s*}}/s'] as $pattern) { + if (! preg_match($pattern, $remaining, $matches)) { + continue; + } + + return [ + 'expression' => $matches[1], + 'original' => $matches[0], + ]; + } + + return null; } /** diff --git a/src/Parser/Tokens/EchoToken.php b/src/Parser/Tokens/EchoToken.php new file mode 100644 index 00000000..51e0fd9d --- /dev/null +++ b/src/Parser/Tokens/EchoToken.php @@ -0,0 +1,11 @@ +parse($input)->nodes)->toEqual([ + new TextNode('Price: '), + new EchoNode('$price', '{{ $price }}'), + new TextNode(' and $plainText'), + ]); +}); diff --git a/tests/Parser/TokenizerTest.php b/tests/Parser/TokenizerTest.php index 867c1bc9..4b6b0d22 100644 --- a/tests/Parser/TokenizerTest.php +++ b/tests/Parser/TokenizerTest.php @@ -4,6 +4,7 @@ use Livewire\Blaze\Parser\Tokens\TagOpenToken; use Livewire\Blaze\Parser\Tokens\TextToken; use Livewire\Blaze\Parser\Tokens\DirectiveToken; +use Livewire\Blaze\Parser\Tokens\EchoToken; use Livewire\Blaze\Parser\Tokens\TagCloseToken; use Livewire\Blaze\Parser\Tokens\PhpBlockToken; @@ -280,4 +281,29 @@ expect($result)->toEqual([ new TagOpenToken(prefix: 'x-', name: 'button', attributes: ' ', original: '', selfClosing: true), ]); -}); \ No newline at end of file +}); + +test('tokenizes Blade echo expressions', function () { + $input = 'Hello {{ $name }} {!! $html !!} {{{ $legacy }}}'; + + $result = app(Tokenizer::class)->tokenize($input); + + expect($result)->toEqual([ + new TextToken('Hello '), + new EchoToken('$name', '{{ $name }}'), + new TextToken(' '), + new EchoToken('$html', '{!! $html !!}'), + new TextToken(' '), + new EchoToken('$legacy', '{{{ $legacy }}}'), + ]); +}); + +test('preserves escaped Blade echo expressions as text', function () { + $input = '@{{ $name }} @{!! $html !!} @{{{ $legacy }}}'; + + $result = app(Tokenizer::class)->tokenize($input); + + expect($result)->toEqual([ + new TextToken($input), + ]); +}); From b0907f10f5eda43146b7cf8509e38fa79546bc46 Mon Sep 17 00:00:00 2001 From: Filip Ganyicz Date: Sun, 9 Aug 2026 23:03:25 +0200 Subject: [PATCH 42/56] Refactor --- src/BladeService.php | 31 ++++++++++++++----------------- 1 file changed, 14 insertions(+), 17 deletions(-) diff --git a/src/BladeService.php b/src/BladeService.php index d05fc762..3d0cc70e 100644 --- a/src/BladeService.php +++ b/src/BladeService.php @@ -10,10 +10,12 @@ use Livewire\Blaze\Parser\Attribute; use ReflectionClass; -// TODO: we should cache the reflection class BladeService { protected ComponentTagCompiler $tagCompiler; + + protected ReflectionClass $compilerReflection; + protected ReflectionClass $tagCompilerReflection; protected ?array $customConditions = null; @@ -26,6 +28,9 @@ public function __construct( $compiler->getClassComponentNamespaces(), $compiler, ); + + $this->compilerReflection = new ReflectionClass($this->compiler); + $this->tagCompilerReflection = new ReflectionClass($this->tagCompiler); } /** @@ -53,8 +58,7 @@ public function earliestPreCompilationHook(callable $callback): void */ public function compileComments(string $input): string { - $reflection = new \ReflectionClass($this->compiler); - $compileComments = $reflection->getMethod('compileComments'); + $compileComments = $this->compilerReflection->getMethod('compileComments'); return $compileComments->invoke($this->compiler, $input); } @@ -64,8 +68,7 @@ public function compileComments(string $input): string */ public function hasEvenNumberOfParentheses(string $expression): bool { - $reflection = new ReflectionClass($this->compiler); - $method = $reflection->getMethod('hasEvenNumberOfParentheses'); + $method = $this->compilerReflection->getMethod('hasEvenNumberOfParentheses'); return $method->invoke($this->compiler, $expression); } @@ -99,8 +102,7 @@ public function preprocessAttributeString(string $attributeString): string public function compileUseStatements(string $expression): string { - $reflection = new \ReflectionClass($this->compiler); - $method = $reflection->getMethod('compileUse'); + $method = $this->compilerReflection->getMethod('compileUse'); return $method->invoke($this->compiler, $expression); } @@ -114,8 +116,7 @@ public function customConditions(): array return $this->customConditions; } - $reflection = new ReflectionClass($this->compiler); - $conditions = $reflection->getProperty('conditions')->getValue($this->compiler); + $conditions = $this->compilerReflection->getProperty('conditions')->getValue($this->compiler); return $this->customConditions = collect($conditions)->keys()->all(); } @@ -145,8 +146,7 @@ public function compileAttribute(Attribute $attribute, bool $escapeBound = false */ public function compileAttributeEchos(string $input): string { - $reflection = new \ReflectionClass($this->tagCompiler); - $method = $reflection->getMethod('compileAttributeEchos'); + $method = $this->tagCompilerReflection->getMethod('compileAttributeEchos'); return Str::unwrap("'".$method->invoke($this->tagCompiler, $input)."'", "''.", ".''"); } @@ -209,8 +209,7 @@ public function componentNameToPath($name): string */ public function hasEchoHandlers(): bool { - $reflection = new ReflectionClass($this->compiler); - $handlers = $reflection->getProperty('echoHandlers')->getValue($this->compiler); + $handlers = $this->compilerReflection->getProperty('echoHandlers')->getValue($this->compiler); return ! empty($handlers); } @@ -245,16 +244,14 @@ protected function hasClassBasedComponent(string $name): bool protected function guessAnonymousComponentUsingNamespaces(Factory $viewFactory, string $component): string|null { - $reflection = new \ReflectionClass($this->tagCompiler); - $method = $reflection->getMethod('guessAnonymousComponentUsingNamespaces'); + $method = $this->tagCompilerReflection->getMethod('guessAnonymousComponentUsingNamespaces'); return $method->invoke($this->tagCompiler, $viewFactory, $component); } protected function guessAnonymousComponentUsingPaths(Factory $viewFactory, string $component): string|null { - $reflection = new \ReflectionClass($this->tagCompiler); - $method = $reflection->getMethod('guessAnonymousComponentUsingPaths'); + $method = $this->tagCompilerReflection->getMethod('guessAnonymousComponentUsingPaths'); return $method->invoke($this->tagCompiler, $viewFactory, $component); } From 4c859a075a5009daccca47f9fb7fab641b06f7c1 Mon Sep 17 00:00:00 2001 From: Filip Ganyicz Date: Sun, 9 Aug 2026 23:18:51 +0200 Subject: [PATCH 43/56] Remove todo --- src/BlazeManager.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/BlazeManager.php b/src/BlazeManager.php index 0492f038..d7b7ebe6 100644 --- a/src/BlazeManager.php +++ b/src/BlazeManager.php @@ -410,7 +410,6 @@ protected function hasAwareDescendant(ComponentNode|SlotNode $node): bool return true; } - // TODO: Not having this condition threw for class-based component like , that should be tested somehow if (! $component) { return false; } From e14da7156c6ffd559660da4ccaeddf011d6cf667 Mon Sep 17 00:00:00 2001 From: Filip Ganyicz Date: Sun, 9 Aug 2026 23:23:56 +0200 Subject: [PATCH 44/56] Refactor --- src/Support/LaravelRegex.php | 40 ------------------------------------ 1 file changed, 40 deletions(-) diff --git a/src/Support/LaravelRegex.php b/src/Support/LaravelRegex.php index 2db20538..1dde1d59 100644 --- a/src/Support/LaravelRegex.php +++ b/src/Support/LaravelRegex.php @@ -12,28 +12,9 @@ * * @see vendor/laravel/framework/src/Illuminate/View/Compilers/ComponentTagCompiler.php * @see vendor/laravel/framework/src/Illuminate/View/Compilers/BladeCompiler.php - * @see vendor/laravel/framework/src/Illuminate/View/Compilers/Concerns/CompilesComments.php - * - * TODO: check if we use all of these */ class LaravelRegex { - /** - * Pattern for matching a component tag name at the current position. - * - * @see ComponentTagCompiler::compileOpeningTags() — x[-\:]([\w\-\:\.]*) - * @see ComponentTagCompiler::compileSelfClosingTags() — x[-\:]([\w\-\:\.]*) - * @see ComponentTagCompiler::compileClosingTags() — x[-\:][\w\-\:\.]* - */ - const TAG_NAME = '/^[\w\-\:\.]*/'; - - /** - * Pattern for matching a slot inline name (e.g., ). - * - * @see ComponentTagCompiler::compileSlots() — line 522, (?:\:(?\w+(?:-\w+)*))? - */ - const SLOT_INLINE_NAME = '/^\w+(?:-\w+)*/'; - /** * Pattern for matching individual attributes after preprocessing. * @@ -55,27 +36,6 @@ class LaravelRegex )? /x'; - /** - * Pattern for matching Blade comments ({{-- ... --}}). - * - * @see CompilesComments::compileComments() — sprintf('/%s--(.*?)--%s/s', contentTags) - */ - const BLADE_COMMENT = '/\{\{--(.*?)--\}\}/s'; - - /** - * Pattern for matching @verbatim...@endverbatim blocks. - * - * @see BladeCompiler::storeVerbatimBlocks() — /(? Date: Sun, 9 Aug 2026 23:31:10 +0200 Subject: [PATCH 45/56] Fix slot injection --- src/Compiler/Wrapper.php | 5 +---- tests/Compiler/WrapperTest.php | 2 ++ 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/Compiler/Wrapper.php b/src/Compiler/Wrapper.php index 6440778f..f8dc82f1 100644 --- a/src/Compiler/Wrapper.php +++ b/src/Compiler/Wrapper.php @@ -105,6 +105,7 @@ protected function globalVariables(array $ast): string { $variables = [ '$__env' => '$__env = $__blaze->env;', + '$slot' => '$__slots[\'slot\'] ??= new \Illuminate\View\ComponentSlot(\'\');', ]; $hasEchoHandlers = $this->blade->hasEchoHandlers(); @@ -126,10 +127,6 @@ protected function globalVariables(array $ast): string $variables['$_instance'] = '$_instance = $__livewire;'; } - if (! isset($variables['$slot']) && $node->containsPhp('$slot')) { - $variables['$slot'] = '$__slots[\'slot\'] ??= new \Illuminate\View\ComponentSlot(\'\');'; - } - if (! isset($variables['$__bladeCompiler']) && $hasEchoHandlers && $node instanceof EchoNode) { $variables['$__bladeCompiler'] = '$__bladeCompiler = app(\'blade.compiler\');'; } diff --git a/tests/Compiler/WrapperTest.php b/tests/Compiler/WrapperTest.php index 5be9429a..038aa1fe 100644 --- a/tests/Compiler/WrapperTest.php +++ b/tests/Compiler/WrapperTest.php @@ -17,6 +17,7 @@ expect($wrapped)->toEqualCollapsingWhitespace(join('', [ 'env; ', + '$__slots[\'slot\'] ??= new \Illuminate\View\ComponentSlot(\'\'); ', 'if (($__data[\'attributes\'] ?? null) instanceof \Illuminate\View\ComponentAttributeBag) { $__data = $__data + $__data[\'attributes\']->all(); unset($__data[\'attributes\']); } ', 'extract($__slots, EXTR_SKIP); unset($__slots); ', 'extract($__data, EXTR_SKIP); ', @@ -44,6 +45,7 @@ expect($wrapped)->toEqualCollapsingWhitespace(join('', [ 'env; ', + '$__slots[\'slot\'] ??= new \Illuminate\View\ComponentSlot(\'\'); ', 'if (($__data[\'attributes\'] ?? null) instanceof \Illuminate\View\ComponentAttributeBag) { $__data = $__data + $__data[\'attributes\']->all(); unset($__data[\'attributes\']); } ', 'extract($__slots, EXTR_SKIP); unset($__slots); ', 'extract($__data, EXTR_SKIP); ', From a9492a79c74bd7df085f5eb4064e9e830b870fca Mon Sep 17 00:00:00 2001 From: Filip Ganyicz Date: Mon, 10 Aug 2026 00:14:51 +0200 Subject: [PATCH 46/56] Add CompiledBlockNode --- src/BlazeManager.php | 3 +- src/Compiler/Compiler.php | 6 ++-- src/Compiler/Profiler.php | 4 +-- src/Compiler/Wrapper.php | 5 +++- src/Folder/Folder.php | 4 +-- src/Memoizer/Memoizer.php | 4 +-- src/Parser/Nodes/CompiledBlockNode.php | 19 +++++++++++++ src/Parser/Nodes/Node.php | 2 +- tests/Compiler/CompilerTest.php | 7 +++++ tests/Compiler/WrapperTest.php | 2 -- tests/Folder/FolderTest.php | 38 +++++++++++++------------- tests/Memoizer/MemoizerTest.php | 4 +-- 12 files changed, 63 insertions(+), 35 deletions(-) create mode 100644 src/Parser/Nodes/CompiledBlockNode.php diff --git a/src/BlazeManager.php b/src/BlazeManager.php index d7b7ebe6..70bedb35 100644 --- a/src/BlazeManager.php +++ b/src/BlazeManager.php @@ -13,6 +13,7 @@ use Livewire\Blaze\Events\ComponentFolded; use Livewire\Blaze\Folder\Folder; use Livewire\Blaze\Memoizer\Memoizer; +use Livewire\Blaze\Parser\Nodes\CompiledBlockNode; use Livewire\Blaze\Parser\Nodes\ComponentNode; use Livewire\Blaze\Parser\Nodes\DirectiveNode; use Livewire\Blaze\Parser\Parser; @@ -143,7 +144,7 @@ public function compileForFolding(string $source, ?string $path = null): string $tag = '[STARTCOMPILEDUNBLAZE:' . $currentUnblazeToken . ']'; $content = 'expression . '); ?>'; - return new TextNode($tag . $content); + return new CompiledBlockNode($tag . $content); } if ($node instanceof DirectiveNode && $node->is('endunblaze') && $currentUnblazeToken) { diff --git a/src/Compiler/Compiler.php b/src/Compiler/Compiler.php index 1f92b042..faac1a51 100644 --- a/src/Compiler/Compiler.php +++ b/src/Compiler/Compiler.php @@ -4,9 +4,9 @@ use Livewire\Blaze\BladeService; use Livewire\Blaze\BlazeManager; +use Livewire\Blaze\Parser\Nodes\CompiledBlockNode; use Livewire\Blaze\Parser\Nodes\ComponentNode; use Livewire\Blaze\Parser\Nodes\SlotNode; -use Livewire\Blaze\Parser\Nodes\TextNode; use Livewire\Blaze\Parser\Nodes\Node; use Livewire\Blaze\Config; use Livewire\Blaze\Support\ComponentRepository; @@ -39,7 +39,7 @@ public function compile(Node $node): Node } if ($node->name === 'flux::delegate-component') { - return new TextNode($this->compileDelegateComponentTag($node)); + return new CompiledBlockNode($this->compileDelegateComponentTag($node)); } $component = $this->components->get($node->name); @@ -56,7 +56,7 @@ public function compile(Node $node): Node return $node; } - return new TextNode($this->compileComponentTag($node, $component)); + return new CompiledBlockNode($this->compileComponentTag($node, $component)); } /** diff --git a/src/Compiler/Profiler.php b/src/Compiler/Profiler.php index 46820aaa..3f3b2041 100644 --- a/src/Compiler/Profiler.php +++ b/src/Compiler/Profiler.php @@ -5,9 +5,9 @@ use Livewire\Blaze\BladeService; use Livewire\Blaze\BlazeManager; use Livewire\Blaze\Config; +use Livewire\Blaze\Parser\Nodes\CompiledBlockNode; use Livewire\Blaze\Parser\Nodes\ComponentNode; use Livewire\Blaze\Parser\Nodes\Node; -use Livewire\Blaze\Parser\Nodes\TextNode; use Livewire\Blaze\Support\ComponentRepository; /** @@ -51,7 +51,7 @@ public function profile(Node $node, string $componentName, ?string $strategy = n .$output .'<'.'?php $__blaze->debugger->stopTimer(\''.$escapedName.'\'); ?>'; - return new TextNode($wrapped); + return new CompiledBlockNode($wrapped); } /** diff --git a/src/Compiler/Wrapper.php b/src/Compiler/Wrapper.php index f8dc82f1..6440778f 100644 --- a/src/Compiler/Wrapper.php +++ b/src/Compiler/Wrapper.php @@ -105,7 +105,6 @@ protected function globalVariables(array $ast): string { $variables = [ '$__env' => '$__env = $__blaze->env;', - '$slot' => '$__slots[\'slot\'] ??= new \Illuminate\View\ComponentSlot(\'\');', ]; $hasEchoHandlers = $this->blade->hasEchoHandlers(); @@ -127,6 +126,10 @@ protected function globalVariables(array $ast): string $variables['$_instance'] = '$_instance = $__livewire;'; } + if (! isset($variables['$slot']) && $node->containsPhp('$slot')) { + $variables['$slot'] = '$__slots[\'slot\'] ??= new \Illuminate\View\ComponentSlot(\'\');'; + } + if (! isset($variables['$__bladeCompiler']) && $hasEchoHandlers && $node instanceof EchoNode) { $variables['$__bladeCompiler'] = '$__bladeCompiler = app(\'blade.compiler\');'; } diff --git a/src/Folder/Folder.php b/src/Folder/Folder.php index 6cd25b0f..81429d40 100644 --- a/src/Folder/Folder.php +++ b/src/Folder/Folder.php @@ -5,10 +5,10 @@ use Illuminate\Support\Facades\Event; use Livewire\Blaze\Events\ComponentFolded; use Livewire\Blaze\Exceptions\InvalidBlazeFoldUsageException; +use Livewire\Blaze\Parser\Nodes\CompiledBlockNode; use Livewire\Blaze\Parser\Nodes\ComponentNode; use Livewire\Blaze\Parser\Nodes\Node; use Livewire\Blaze\Parser\Nodes\SlotNode; -use Livewire\Blaze\Parser\Nodes\TextNode; use Livewire\Blaze\Support\ComponentSource; use Livewire\Blaze\BladeRenderer; use Livewire\Blaze\BladeService; @@ -71,7 +71,7 @@ public function fold(Node $node): Node filemtime: filemtime($component->path), )); - return new TextNode('' . $html . ''); + return new CompiledBlockNode('' . $html . ''); } catch (Throwable $th) { if ($this->manager->shouldThrow()) { throw $th; diff --git a/src/Memoizer/Memoizer.php b/src/Memoizer/Memoizer.php index ad510e77..b1342461 100644 --- a/src/Memoizer/Memoizer.php +++ b/src/Memoizer/Memoizer.php @@ -4,8 +4,8 @@ use Livewire\Blaze\BladeService; use Livewire\Blaze\BlazeManager; +use Livewire\Blaze\Parser\Nodes\CompiledBlockNode; use Livewire\Blaze\Parser\Nodes\ComponentNode; -use Livewire\Blaze\Parser\Nodes\TextNode; use Livewire\Blaze\Parser\Nodes\Node; use Livewire\Blaze\Config; use Livewire\Blaze\Compiler\Compiler; @@ -66,7 +66,7 @@ public function memoize(Node $node): Node $output .= '<' . '?php echo $blaze_memoized_html; ?>'; $output .= '<' . '?php endif; ?>'; - return new TextNode($output); + return new CompiledBlockNode($output); } /** diff --git a/src/Parser/Nodes/CompiledBlockNode.php b/src/Parser/Nodes/CompiledBlockNode.php new file mode 100644 index 00000000..04b5a817 --- /dev/null +++ b/src/Parser/Nodes/CompiledBlockNode.php @@ -0,0 +1,19 @@ +content; + } +} diff --git a/src/Parser/Nodes/Node.php b/src/Parser/Nodes/Node.php index 609f25fb..0048454a 100644 --- a/src/Parser/Nodes/Node.php +++ b/src/Parser/Nodes/Node.php @@ -14,7 +14,7 @@ abstract public function render(): string; public function containsPhp(string $php): bool { - if ($this instanceof PhpBlockNode) { + if ($this instanceof PhpBlockNode || $this instanceof CompiledBlockNode) { if (str_contains($this->content, $php)) { return true; } diff --git a/tests/Compiler/CompilerTest.php b/tests/Compiler/CompilerTest.php index cb82f489..5674decd 100644 --- a/tests/Compiler/CompilerTest.php +++ b/tests/Compiler/CompilerTest.php @@ -3,6 +3,7 @@ use Livewire\Blaze\Parser\Parser; use Livewire\Blaze\Compiler\Compiler; use Livewire\Blaze\Config; +use Livewire\Blaze\Parser\Nodes\CompiledBlockNode; use Livewire\Blaze\Parser\Nodes\ComponentNode; use Livewire\Blaze\Support\Utils; @@ -12,6 +13,8 @@ $node = app(Parser::class)->parse($input)->nodes[0]; $compiled = app(Compiler::class)->compile($node); + expect($compiled)->toBeInstanceOf(CompiledBlockNode::class); + $path = fixture_path('views/components/input.blade.php'); $hash = Utils::hash($path); @@ -40,6 +43,8 @@ $node = app(Parser::class)->parse($input)->nodes[0]; $compiled = app(Compiler::class)->compile($node); + expect($compiled)->toBeInstanceOf(CompiledBlockNode::class); + $path = fixture_path('views/components/card.blade.php'); $hash = Utils::hash($path); @@ -69,6 +74,8 @@ $node = app(Parser::class)->parse($input)->nodes[0]; $compiled = app(Compiler::class)->compile($node); + expect($compiled)->toBeInstanceOf(CompiledBlockNode::class); + expect($compiled->render())->toEqualCollapsingWhitespace(join('', [ 'resolve(\'flux::\' . card); ?> ', 'unescapeAttributes($attributes->getAttributes()); ?> ', diff --git a/tests/Compiler/WrapperTest.php b/tests/Compiler/WrapperTest.php index 038aa1fe..5be9429a 100644 --- a/tests/Compiler/WrapperTest.php +++ b/tests/Compiler/WrapperTest.php @@ -17,7 +17,6 @@ expect($wrapped)->toEqualCollapsingWhitespace(join('', [ 'env; ', - '$__slots[\'slot\'] ??= new \Illuminate\View\ComponentSlot(\'\'); ', 'if (($__data[\'attributes\'] ?? null) instanceof \Illuminate\View\ComponentAttributeBag) { $__data = $__data + $__data[\'attributes\']->all(); unset($__data[\'attributes\']); } ', 'extract($__slots, EXTR_SKIP); unset($__slots); ', 'extract($__data, EXTR_SKIP); ', @@ -45,7 +44,6 @@ expect($wrapped)->toEqualCollapsingWhitespace(join('', [ 'env; ', - '$__slots[\'slot\'] ??= new \Illuminate\View\ComponentSlot(\'\'); ', 'if (($__data[\'attributes\'] ?? null) instanceof \Illuminate\View\ComponentAttributeBag) { $__data = $__data + $__data[\'attributes\']->all(); unset($__data[\'attributes\']); } ', 'extract($__slots, EXTR_SKIP); unset($__slots); ', 'extract($__data, EXTR_SKIP); ', diff --git a/tests/Folder/FolderTest.php b/tests/Folder/FolderTest.php index 82fa2309..64afdd5e 100644 --- a/tests/Folder/FolderTest.php +++ b/tests/Folder/FolderTest.php @@ -3,7 +3,7 @@ use Livewire\Blaze\Config; use Livewire\Blaze\Parser\Parser; use Livewire\Blaze\Folder\Folder; -use Livewire\Blaze\Parser\Nodes\TextNode; +use Livewire\Blaze\Parser\Nodes\CompiledBlockNode; use Livewire\Blaze\Parser\Nodes\ComponentNode; use Livewire\Blaze\Exceptions\InvalidBlazeFoldUsageException; use Livewire\Blaze\Support\AttributeParser; @@ -14,7 +14,7 @@ $node = app(Parser::class)->parse($input)->nodes[0]; $folded = app(Folder::class)->fold($node); - expect($folded)->toBeInstanceOf(TextNode::class); + expect($folded)->toBeInstanceOf(CompiledBlockNode::class); }); test('does not fold components with dynamic prop attributes', function () { @@ -32,7 +32,7 @@ $node = app(Parser::class)->parse($input)->nodes[0]; $folded = app(Folder::class)->fold($node); - expect($folded)->toBeInstanceOf(TextNode::class); + expect($folded)->toBeInstanceOf(CompiledBlockNode::class); }); test('folds components with dynamic prop attributes with boolean values', function ($value) { @@ -41,7 +41,7 @@ $node = app(Parser::class)->parse($input)->nodes[0]; $folded = app(Folder::class)->fold($node); - expect($folded)->toBeInstanceOf(TextNode::class); + expect($folded)->toBeInstanceOf(CompiledBlockNode::class); })->with(['true', 'false']); test('folds components with dynamic prop attributes with null value', function () { @@ -50,7 +50,7 @@ $node = app(Parser::class)->parse($input)->nodes[0]; $folded = app(Folder::class)->fold($node); - expect($folded)->toBeInstanceOf(TextNode::class); + expect($folded)->toBeInstanceOf(CompiledBlockNode::class); }); test('fold components with dynamic prop attributes marked as safe', function () { @@ -59,7 +59,7 @@ $node = app(Parser::class)->parse($input)->nodes[0]; $folded = app(Folder::class)->fold($node); - expect($folded)->toBeInstanceOf(TextNode::class); + expect($folded)->toBeInstanceOf(CompiledBlockNode::class); }); test('does not fold components with dynamic non-prop attributes marked as unsafe', function () { @@ -86,7 +86,7 @@ $node = app(Parser::class)->parse($input)->nodes[0]; $folded = app(Folder::class)->fold($node); - expect($folded)->toBeInstanceOf(TextNode::class); + expect($folded)->toBeInstanceOf(CompiledBlockNode::class); }); test('does not fold components with slots marked as unsafe', function () { @@ -113,7 +113,7 @@ $node = app(Parser::class)->parse($input)->nodes[0]; $folded = app(Folder::class)->fold($node); - expect($folded)->toBeInstanceOf(TextNode::class); + expect($folded)->toBeInstanceOf(CompiledBlockNode::class); }); test('does not fold components with dynamic non-prop attributes with unsafe wildcard', function () { @@ -131,7 +131,7 @@ $node = app(Parser::class)->parse($input)->nodes[0]; $folded = app(Folder::class)->fold($node); - expect($folded)->toBeInstanceOf(TextNode::class); + expect($folded)->toBeInstanceOf(CompiledBlockNode::class); }); test('does not fold components with slots with unsafe wildcard', function () { @@ -167,7 +167,7 @@ $node = app(Parser::class)->parse($input)->nodes[0]; $folded = app(Folder::class)->fold($node); - expect($folded)->toBeInstanceOf(TextNode::class); + expect($folded)->toBeInstanceOf(CompiledBlockNode::class); }); test('folds components with named only slots and whitespace with unsafe slot keyword', function () { @@ -178,7 +178,7 @@ $node = app(Parser::class)->parse($input)->nodes[0]; $folded = app(Folder::class)->fold($node); - expect($folded)->toBeInstanceOf(TextNode::class); + expect($folded)->toBeInstanceOf(CompiledBlockNode::class); }); test('does not fold components with dynamic non-prop attributes with unsafe attributes keyword', function () { @@ -196,7 +196,7 @@ $node = app(Parser::class)->parse($input)->nodes[0]; $folded = app(Folder::class)->fold($node); - expect($folded)->toBeInstanceOf(TextNode::class); + expect($folded)->toBeInstanceOf(CompiledBlockNode::class); }); test('does not fold components with dynamic aware prop from parent', function () { @@ -222,7 +222,7 @@ $folded = app(Folder::class)->fold($node); - expect($folded)->toBeInstanceOf(TextNode::class); + expect($folded)->toBeInstanceOf(CompiledBlockNode::class); }); test('folds components with static aware prop from parent', function () { @@ -235,7 +235,7 @@ $folded = app(Folder::class)->fold($node); - expect($folded)->toBeInstanceOf(TextNode::class); + expect($folded)->toBeInstanceOf(CompiledBlockNode::class); }); test('does not fold components with no blaze directive', function () { @@ -266,7 +266,7 @@ $node = app(Parser::class)->parse($input)->nodes[0]; $folded = app(Folder::class)->fold($node); - expect($folded)->toBeInstanceOf(TextNode::class); + expect($folded)->toBeInstanceOf(CompiledBlockNode::class); }); test('folds components with blaze directive even if disabled in config', function () { @@ -277,7 +277,7 @@ $node = app(Parser::class)->parse($input)->nodes[0]; $folded = app(Folder::class)->fold($node); - expect($folded)->toBeInstanceOf(TextNode::class); + expect($folded)->toBeInstanceOf(CompiledBlockNode::class); }); test('throws exception for components with problematic patterns', function (string $component) { @@ -304,7 +304,7 @@ $node = app(Parser::class)->parse($input)->nodes[0]; $result = app(Folder::class)->fold($node); - expect($result)->toBeInstanceOf(TextNode::class); + expect($result)->toBeInstanceOf(CompiledBlockNode::class); }); test('folds components with closing directives outside slot', function () { @@ -313,7 +313,7 @@ $node = app(Parser::class)->parse($input)->nodes[0]; $result = app(Folder::class)->fold($node); - expect($result)->toBeInstanceOf(TextNode::class); + expect($result)->toBeInstanceOf(CompiledBlockNode::class); }); test('folds components with non-closing directive before slot followed by closing directive', function () { @@ -322,5 +322,5 @@ $node = app(Parser::class)->parse($input)->nodes[0]; $result = app(Folder::class)->fold($node); - expect($result)->toBeInstanceOf(TextNode::class); + expect($result)->toBeInstanceOf(CompiledBlockNode::class); }); diff --git a/tests/Memoizer/MemoizerTest.php b/tests/Memoizer/MemoizerTest.php index b6eef885..defd5cea 100644 --- a/tests/Memoizer/MemoizerTest.php +++ b/tests/Memoizer/MemoizerTest.php @@ -4,7 +4,7 @@ use Livewire\Blaze\Parser\Parser; use Livewire\Blaze\Support\Utils; use Livewire\Blaze\Parser\Nodes\ComponentNode; -use Livewire\Blaze\Parser\Nodes\TextNode; +use Livewire\Blaze\Parser\Nodes\CompiledBlockNode; use Livewire\Blaze\Config; test('memoizes self-closing components', function () { @@ -63,7 +63,7 @@ $node = app(Parser::class)->parse($input)->nodes[0]; $memoized = app(Memoizer::class)->memoize($node); - expect($memoized)->toBeInstanceOf(TextNode::class); + expect($memoized)->toBeInstanceOf(CompiledBlockNode::class); }); test('does not memoize components with blaze directive override set to false', function () { From 49ee1d885105f02c50786790cc666ee11e093b51 Mon Sep 17 00:00:00 2001 From: Filip Ganyicz Date: Mon, 10 Aug 2026 01:11:58 +0200 Subject: [PATCH 47/56] Handle dynamic slot names --- src/Compiler/Compiler.php | 4 +-- src/Folder/Folder.php | 2 +- src/Parser/Attribute.php | 15 +++++++++ src/Parser/Nodes/ComponentNode.php | 10 +----- src/Parser/Nodes/Node.php | 4 +++ src/Parser/Nodes/SlotNode.php | 10 +++++- src/Parser/Parser.php | 6 ++-- tests/Compiler/CompilerTest.php | 18 +++++++++++ tests/Folder/FolderTest.php | 18 +++++++++++ tests/Parser/AttributeTest.php | 13 ++++++++ tests/Parser/Nodes/ComponentNodeTest.php | 39 ++++++++++++++++++++++++ tests/Parser/Nodes/SlotNodeTest.php | 25 +++++++++++++++ tests/Parser/ParserTest.php | 13 ++++++++ 13 files changed, 161 insertions(+), 16 deletions(-) create mode 100644 tests/Parser/Nodes/ComponentNodeTest.php create mode 100644 tests/Parser/Nodes/SlotNodeTest.php diff --git a/src/Compiler/Compiler.php b/src/Compiler/Compiler.php index faac1a51..c83438d1 100644 --- a/src/Compiler/Compiler.php +++ b/src/Compiler/Compiler.php @@ -75,13 +75,11 @@ protected function shouldCompile(ComponentSource $source): bool /** * Check if any slot has a dynamic name (:name="$var"). - * - * TODO: Is this even real? Does Laravel support this? */ protected function hasDynamicSlotNames(ComponentNode $node): bool { foreach ($node->children as $child) { - if ($child instanceof SlotNode && str_starts_with($child->name, '$')) { // TODO: Double check this + if ($child instanceof SlotNode && $child->hasDynamicName()) { return true; } } diff --git a/src/Folder/Folder.php b/src/Folder/Folder.php index 81429d40..dd4ca32e 100644 --- a/src/Folder/Folder.php +++ b/src/Folder/Folder.php @@ -121,7 +121,7 @@ protected function isSafeToFold(ComponentSource $source, ComponentNode $node): b foreach ($node->children as $child) { if ($child instanceof SlotNode) { - if ($this->slotHasDynamicAttributes($child)) { + if ($child->hasDynamicName() || $this->slotHasDynamicAttributes($child)) { return false; } } diff --git a/src/Parser/Attribute.php b/src/Parser/Attribute.php index bd75400b..fecdf2b6 100644 --- a/src/Parser/Attribute.php +++ b/src/Parser/Attribute.php @@ -26,6 +26,21 @@ public function bound(): bool return $this->prefix === ':' || $this->prefix === ':$'; } + public function render(): string + { + if ($this->valueless) { + return $this->name; + } + + $output = $this->prefix . $this->name; + + if ($this->prefix !== ':$') { + $output .= '=' . $this->quotes . $this->value . $this->quotes; + } + + return $output; + } + /** * Check if the attribute value can be resolved at compile time. */ diff --git a/src/Parser/Nodes/ComponentNode.php b/src/Parser/Nodes/ComponentNode.php index c123ba7d..bd82485f 100644 --- a/src/Parser/Nodes/ComponentNode.php +++ b/src/Parser/Nodes/ComponentNode.php @@ -54,15 +54,7 @@ public function render(): string $output = "<{$this->prefix}{$name}"; foreach ($this->attributes as $attribute) { - if ($attribute->valueless) { - $output .= ' '.$attribute->name; - } else { - $output .= ' '.$attribute->prefix.$attribute->name; - - if ($attribute->prefix !== ':$') { - $output .= '='.$attribute->quotes.$attribute->value.$attribute->quotes; - } - } + $output .= ' ' . $attribute->render(); } if ($this->selfClosing) { diff --git a/src/Parser/Nodes/Node.php b/src/Parser/Nodes/Node.php index 0048454a..e4dbbe1d 100644 --- a/src/Parser/Nodes/Node.php +++ b/src/Parser/Nodes/Node.php @@ -28,6 +28,10 @@ public function containsPhp(string $php): bool if (str_contains($this->attributeString, $php)) { return true; } + + if ($this instanceof SlotNode && $this->hasDynamicName() && str_contains($this->name, $php)) { + return true; + } } if ($this instanceof DirectiveNode) { diff --git a/src/Parser/Nodes/SlotNode.php b/src/Parser/Nodes/SlotNode.php index 6268585b..a4602dca 100644 --- a/src/Parser/Nodes/SlotNode.php +++ b/src/Parser/Nodes/SlotNode.php @@ -18,9 +18,15 @@ public function __construct( public bool $closeHasName = false, /** @var Attribute[] */ public array $attributes = [], + public ?Attribute $nameAttribute = null, ) { } + public function hasDynamicName(): bool + { + return $this->nameAttribute?->dynamic === true; + } + /** {@inheritdoc} */ public function render(): string { @@ -47,7 +53,9 @@ public function render(): string $output = "<{$this->prefix}"; - if (! empty($this->name)) { + if ($this->nameAttribute) { + $output .= ' ' . $this->nameAttribute->render(); + } elseif (! empty($this->name)) { $output .= ' name="' . $this->name . '"'; } diff --git a/src/Parser/Parser.php b/src/Parser/Parser.php index a2385f46..0201d580 100644 --- a/src/Parser/Parser.php +++ b/src/Parser/Parser.php @@ -114,13 +114,14 @@ protected function handleSlotOpen(TagOpenToken $token, ParseStack $stack): void $attributeString = $token->attributes; $attributes = $this->attributes->parse($token->attributes); + $nameAttribute = null; $name = $short ? substr($token->name, strlen('slot:')) : ($attributes['name'] ?? 'slot'); if (! $short && isset($attributes['name'])) { - // TODO: We should be able to handle dynamic slot names... + $nameAttribute = $attributes['name']->dynamic ? $attributes['name'] : null; $name = $attributes['name']->value; - $attributeString = preg_replace('/(?:^|\s+)name\s*=\s*(["\']).*?\1/', '', $token->attributes, 1); + $attributeString = preg_replace('/(?:^|\s+):?name\s*=\s*(?:"[^"]*"|\'[^\']*\'|[^\s>]+)/', '', $token->attributes, 1); unset($attributes['name']); } @@ -133,6 +134,7 @@ protected function handleSlotOpen(TagOpenToken $token, ParseStack $stack): void prefix: $token->prefix . 'slot', closeHasName: false, attributes: $attributes, + nameAttribute: $nameAttribute, ); $stack->pushContainer($node); diff --git a/tests/Compiler/CompilerTest.php b/tests/Compiler/CompilerTest.php index 5674decd..4e7070e5 100644 --- a/tests/Compiler/CompilerTest.php +++ b/tests/Compiler/CompilerTest.php @@ -68,6 +68,24 @@ ])); }); +test('does not compile components with dynamic slot names', function () { + $input = 'Footer'; + $node = app(Parser::class)->parse($input)->nodes[0]; + + expect(app(Compiler::class)->compile($node)) + ->toBe($node) + ->and($node->render())->toBe($input); +}); + +test('does not compile components with slot names containing Blade echoes', function () { + $input = 'Footer'; + $node = app(Parser::class)->parse($input)->nodes[0]; + + expect(app(Compiler::class)->compile($node)) + ->toBe($node) + ->and($node->render())->toBe($input); +}); + test('compiles delegate components', function () { $input = ''; diff --git a/tests/Folder/FolderTest.php b/tests/Folder/FolderTest.php index 64afdd5e..f00f81a6 100644 --- a/tests/Folder/FolderTest.php +++ b/tests/Folder/FolderTest.php @@ -107,6 +107,24 @@ expect($folded)->toBeInstanceOf(ComponentNode::class); }); +test('does not fold components with dynamic slot names', function () { + $input = 'Content'; + + $node = app(Parser::class)->parse($input)->nodes[0]; + $folded = app(Folder::class)->fold($node); + + expect($folded)->toBeInstanceOf(ComponentNode::class); +}); + +test('does not fold components with slot names containing Blade echoes', function () { + $input = 'Content'; + + $node = app(Parser::class)->parse($input)->nodes[0]; + $folded = app(Folder::class)->fold($node); + + expect($folded)->toBeInstanceOf(ComponentNode::class); +}); + test('folds components with dynamic prop attributes with safe wildcard', function () { $input = ''; diff --git a/tests/Parser/AttributeTest.php b/tests/Parser/AttributeTest.php index f380d5ed..043ae7f8 100644 --- a/tests/Parser/AttributeTest.php +++ b/tests/Parser/AttributeTest.php @@ -1,5 +1,6 @@ getStaticValue(); })->throws(LogicException::class); + +test('renders attributes', function ($source, $expected) { + $attribute = Arr::first(app(AttributeParser::class)->parse($source)); + + expect($attribute->render())->toBe($expected); +})->with([ + 'static' => ['foo="bar"', 'foo="bar"'], + 'bound' => [':foo="$bar"', ':foo="$bar"'], + 'short bound' => [':$foo', ':foo="$foo"'], + 'valueless' => ['disabled', 'disabled'], + 'single quotes' => ["foo='bar'", "foo='bar'"], +]); diff --git a/tests/Parser/Nodes/ComponentNodeTest.php b/tests/Parser/Nodes/ComponentNodeTest.php new file mode 100644 index 00000000..5e67e2cd --- /dev/null +++ b/tests/Parser/Nodes/ComponentNodeTest.php @@ -0,0 +1,39 @@ +render())->toBe('Content'); +}); + +test('renders self-closing components', function () { + $component = new ComponentNode( + name: 'button', + prefix: 'x:', + selfClosing: true, + ); + + expect($component->render())->toBe(''); +}); + +test('renders namespaced Flux components', function () { + $component = new ComponentNode( + name: 'flux::button', + prefix: 'flux:', + ); + + expect($component->render())->toBe(''); +}); diff --git a/tests/Parser/Nodes/SlotNodeTest.php b/tests/Parser/Nodes/SlotNodeTest.php new file mode 100644 index 00000000..b473ee4f --- /dev/null +++ b/tests/Parser/Nodes/SlotNodeTest.php @@ -0,0 +1,25 @@ +render())->toBe('Footer'); +}); + +test('renders short slots', function () { + $slot = new SlotNode( + name: 'footer', + slotStyle: 'short', + children: [new TextNode('Footer')], + closeHasName: true, + ); + + expect($slot->render())->toBe('Footer'); +}); diff --git a/tests/Parser/ParserTest.php b/tests/Parser/ParserTest.php index 4cf0480a..aae3aed2 100644 --- a/tests/Parser/ParserTest.php +++ b/tests/Parser/ParserTest.php @@ -60,6 +60,19 @@ ]); }); +test('parses slot name attributes separately', function () { + $input = 'Footer'; + + $slot = app(Parser::class)->parse($input)->nodes[0]->children[0]; + + expect($slot) + ->name->toBe('$name') + ->attributeString->toBe('class="p-2"') + ->nameAttribute->dynamic->toBeTrue() + ->nameAttribute->prefix->toBe(':') + ->attributes->not->toHaveKey('name'); +}); + test('parses named slots with short syntax', function () { $input = 'Footer'; From 88c9f17e05f658d735b6655874b80f5a1215c9bf Mon Sep 17 00:00:00 2001 From: Filip Ganyicz Date: Mon, 10 Aug 2026 10:09:04 +0200 Subject: [PATCH 48/56] Fix container state --- src/BlazeManager.php | 5 ++--- src/BlazeServiceProvider.php | 7 +++++++ src/Parser/Parser.php | 5 +++++ src/Support/ComponentRepository.php | 7 ++++++- 4 files changed, 20 insertions(+), 4 deletions(-) diff --git a/src/BlazeManager.php b/src/BlazeManager.php index 70bedb35..d1ee89c2 100644 --- a/src/BlazeManager.php +++ b/src/BlazeManager.php @@ -37,14 +37,12 @@ class BlazeManager protected $foldedEvents = []; protected $expiredMemo = []; - protected Parser $parser; protected Compiler $compiler; protected Folder $folder; protected Memoizer $memoizer; protected Wrapper $wrapper; protected Profiler $instrumenter; protected BladeRenderer $renderer; - protected ComponentRepository $components; public function __construct( protected Config $config, @@ -52,9 +50,10 @@ public function __construct( protected BlazeRuntime $runtime, protected BladeService $blade, protected Factory $factory, + protected ComponentRepository $components, + protected Parser $parser, ) { $this->renderer = new BladeRenderer($bladeCompiler, $factory, $this->runtime, $this); - $this->parser = new Parser(new Tokenizer($this->blade), new AttributeParser($this->blade)); $this->components = new ComponentRepository($this->blade, $this->parser); $this->compiler = new Compiler($config, $this->blade, $this, $this->components); $this->folder = new Folder($config, $this->blade, $this->renderer, $this, $this->components); diff --git a/src/BlazeServiceProvider.php b/src/BlazeServiceProvider.php index 2bc23bb0..7661c7d7 100644 --- a/src/BlazeServiceProvider.php +++ b/src/BlazeServiceProvider.php @@ -12,6 +12,7 @@ use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\View; use Livewire\Blaze\Memoizer\Memo; +use Livewire\Blaze\Parser\Parser; use Livewire\Blaze\Support\ComponentRepository; class BlazeServiceProvider extends ServiceProvider @@ -26,6 +27,8 @@ public function register(): void $this->app->singleton(Debugger::class); $this->app->singleton(Profiler::class); $this->app->singleton(BlazeManager::class); + $this->app->singleton(ComponentRepository::class); + $this->app->singleton(Parser::class); $this->app->singleton(\PhpParser\Parser::class, function () { return (new \PhpParser\ParserFactory)->createForNewestSupportedVersion(); @@ -194,12 +197,16 @@ protected function registerOctaneListener(): void $runtime = $app->make(BlazeRuntime::class); $manager = $app->make(BlazeManager::class); $debugger = $app->make(Debugger::class); + $parser = $app->make(Parser::class); + $components = $app->make(ComponentRepository::class); $runtime->setApplication($app); $runtime->flushState(); $manager->flushState(); $debugger->flushState(); + $parser->flushState(); + $components->flushState(); Unblaze::flushState(); Memo::flushState(); diff --git a/src/Parser/Parser.php b/src/Parser/Parser.php index 0201d580..2cf8feec 100644 --- a/src/Parser/Parser.php +++ b/src/Parser/Parser.php @@ -188,4 +188,9 @@ protected function handleVerbatimBlock(VerbatimBlockToken $token, ParseStack $st $stack->addToRoot($node); } + + public function flushState(): void + { + $this->templates = []; + } } diff --git a/src/Support/ComponentRepository.php b/src/Support/ComponentRepository.php index 3422c117..6d65cc36 100644 --- a/src/Support/ComponentRepository.php +++ b/src/Support/ComponentRepository.php @@ -31,4 +31,9 @@ public function get(string $name): ?ComponentSource return $this->components[$name] = new ComponentSource($name, $path, $ast); } -} \ No newline at end of file + + public function flushState(): void + { + $this->components = []; + } +} From fe30dc93a7b7f5a9df71d548ab021abb8adc510b Mon Sep 17 00:00:00 2001 From: Filip Ganyicz Date: Mon, 10 Aug 2026 10:11:58 +0200 Subject: [PATCH 49/56] Fix --- src/BlazeManager.php | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/BlazeManager.php b/src/BlazeManager.php index d1ee89c2..09c78b7b 100644 --- a/src/BlazeManager.php +++ b/src/BlazeManager.php @@ -410,11 +410,7 @@ protected function hasAwareDescendant(ComponentNode|SlotNode $node): bool return true; } - if (! $component) { - return false; - } - - if ($component->template->directives->has('aware')) { + if ($component?->template->directives->has('aware')) { return true; } From 06149902d917eaa38a503839dd9b90377522410f Mon Sep 17 00:00:00 2001 From: Filip Ganyicz Date: Mon, 10 Aug 2026 10:15:55 +0200 Subject: [PATCH 50/56] Clone nodes --- src/Parser/Walker.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Parser/Walker.php b/src/Parser/Walker.php index 93b7a608..e97bef5a 100644 --- a/src/Parser/Walker.php +++ b/src/Parser/Walker.php @@ -18,6 +18,8 @@ public static function walk(array $nodes, callable $preCallback, callable $postC $result = []; foreach ($nodes as $node) { + $node = clone $node; + $node = $preCallback($node) ?? $node; if (($node instanceof ComponentNode || $node instanceof SlotNode) && !empty($node->children)) { From 6b71107960c6a73b77462a11d1edaa4dc3900978 Mon Sep 17 00:00:00 2001 From: Filip Ganyicz Date: Mon, 10 Aug 2026 10:26:35 +0200 Subject: [PATCH 51/56] Fix --- src/BlazeManager.php | 1 - src/Runtime/BlazeRuntime.php | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/BlazeManager.php b/src/BlazeManager.php index 09c78b7b..f4dd331c 100644 --- a/src/BlazeManager.php +++ b/src/BlazeManager.php @@ -54,7 +54,6 @@ public function __construct( protected Parser $parser, ) { $this->renderer = new BladeRenderer($bladeCompiler, $factory, $this->runtime, $this); - $this->components = new ComponentRepository($this->blade, $this->parser); $this->compiler = new Compiler($config, $this->blade, $this, $this->components); $this->folder = new Folder($config, $this->blade, $this->renderer, $this, $this->components); $this->memoizer = new Memoizer($config, $this->compiler, $this->blade, $this, $this->components); diff --git a/src/Runtime/BlazeRuntime.php b/src/Runtime/BlazeRuntime.php index cafc659d..ba3588e8 100644 --- a/src/Runtime/BlazeRuntime.php +++ b/src/Runtime/BlazeRuntime.php @@ -73,7 +73,7 @@ public function resolve(string $name): string|false $compiled = $this->getCompiledPath().'/'.$component->hash.'.php'; - if (! isset($this->required[$component->path])) { + if (! isset($this->required[$compiled])) { $this->ensureRequired($component->path, $compiled); } From d7a8d6851530113ba191b93b5b83a208e893ee10 Mon Sep 17 00:00:00 2001 From: Filip Ganyicz Date: Mon, 10 Aug 2026 10:35:11 +0200 Subject: [PATCH 52/56] Formatting --- src/Support/ComponentRepository.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Support/ComponentRepository.php b/src/Support/ComponentRepository.php index 6d65cc36..91ff2721 100644 --- a/src/Support/ComponentRepository.php +++ b/src/Support/ComponentRepository.php @@ -27,9 +27,9 @@ public function get(string $name): ?ComponentSource return $this->components[$name] = null; } - $ast = $this->parser->parse(file_get_contents($path), $path); + $template = $this->parser->parse(file_get_contents($path), $path); - return $this->components[$name] = new ComponentSource($name, $path, $ast); + return $this->components[$name] = new ComponentSource($name, $path, $template); } public function flushState(): void From 9498cfb656c3851442a9a182782eec57d405266b Mon Sep 17 00:00:00 2001 From: Filip Ganyicz Date: Mon, 10 Aug 2026 10:41:43 +0200 Subject: [PATCH 53/56] Fix whitespace --- src/Parser/Tokenizer.php | 8 +++++--- tests/Parser/TokenizerTest.php | 14 +++++++------- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/src/Parser/Tokenizer.php b/src/Parser/Tokenizer.php index f226cd99..0566aeed 100644 --- a/src/Parser/Tokenizer.php +++ b/src/Parser/Tokenizer.php @@ -88,9 +88,11 @@ protected function process(): void $this->flushBuffer(PhpBlockToken::class); } else { - $this->emitToken(new DirectiveToken($match['name'], $match['original'])); + $original = rtrim($match['original']); - $this->rewind($offset + strlen($match['original'])); + $this->emitToken(new DirectiveToken($match['name'], $original)); + + $this->rewind($offset + strlen($original)); } return; @@ -268,7 +270,7 @@ protected function matchOpeningTag(): array|null 'original' => $matches[0], 'prefix' => $matches[1], 'name' => $matches[2], - 'attributes' => $matches['attributes'], + 'attributes' => ltrim($matches['attributes']), 'selfClosing' => $matches['selfClosing'] === '/', ]; } diff --git a/tests/Parser/TokenizerTest.php b/tests/Parser/TokenizerTest.php index 4b6b0d22..2fd6cbb7 100644 --- a/tests/Parser/TokenizerTest.php +++ b/tests/Parser/TokenizerTest.php @@ -24,7 +24,7 @@ $result = app(Tokenizer::class)->tokenize($input); expect($result)->toEqual([ - new TagOpenToken(prefix: 'x-', name: 'button', attributes: ' type="button"', original: '', selfClosing: false), + new TagOpenToken(prefix: 'x-', name: 'button', attributes: 'type="button"', original: '', selfClosing: false), new TagCloseToken(prefix: 'x-', name: 'button', original: ''), ]); }); @@ -35,7 +35,7 @@ $result = app(Tokenizer::class)->tokenize($input); expect($result)->toEqual([ - new TagOpenToken(prefix: 'x-', name: 'button', attributes: ' type="button" ', original: '', selfClosing: true), + new TagOpenToken(prefix: 'x-', name: 'button', attributes: 'type="button" ', original: '', selfClosing: true), ]); }); @@ -45,7 +45,7 @@ $result = app(Tokenizer::class)->tokenize($input); expect($result)->toEqual([ - new TagOpenToken(prefix: 'flux:', name: 'button', attributes: ' type="button"', original: '', selfClosing: false), + new TagOpenToken(prefix: 'flux:', name: 'button', attributes: 'type="button"', original: '', selfClosing: false), new TagCloseToken(prefix: 'flux:', name: 'button', original: ''), ]); }); @@ -119,11 +119,11 @@ $input = '@php $value = "";'; expect(app(Tokenizer::class)->tokenize($input))->toEqual([ - new DirectiveToken(name: 'php', original: '@php '), // <-- TODO: weird whitespace - new TextToken(content: '$value = "'), + new DirectiveToken(name: 'php', original: '@php'), + new TextToken(content: ' $value = "'), new TagOpenToken( prefix: 'x-', name: 'button', - attributes: ' ', // <-- TODO: weird whitespace + attributes: '', original: '', selfClosing: true, ), @@ -279,7 +279,7 @@ $result = app(Tokenizer::class)->tokenize($input); expect($result)->toEqual([ - new TagOpenToken(prefix: 'x-', name: 'button', attributes: ' ', original: '', selfClosing: true), + new TagOpenToken(prefix: 'x-', name: 'button', attributes: '', original: '', selfClosing: true), ]); }); From f8859412bb81d332183835a2745dc76031770664 Mon Sep 17 00:00:00 2001 From: Filip Ganyicz Date: Tue, 11 Aug 2026 13:15:18 +0200 Subject: [PATCH 54/56] Remove unused slot name resolver --- src/Parser/Nodes/ComponentNode.php | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/src/Parser/Nodes/ComponentNode.php b/src/Parser/Nodes/ComponentNode.php index bd82485f..3f25736b 100644 --- a/src/Parser/Nodes/ComponentNode.php +++ b/src/Parser/Nodes/ComponentNode.php @@ -22,22 +22,6 @@ public function __construct( ) { } - /** - * Resolve the slot name, handling both short () and standard syntax. - */ - protected function resolveSlotName(SlotNode $slot): string - { - if (! empty($slot->name)) { - return $slot->name; - } - - if (preg_match('/(?:^|\s)name\s*=\s*["\']([^"\']+)["\']/', $slot->attributeString, $matches)) { - return $matches[1]; - } - - return 'slot'; - } - /** * Set the accumulated parent component attributes for @aware resolution. */ From 191287094b3f7e44cba9495db79adfeeddb050b9 Mon Sep 17 00:00:00 2001 From: Filip Ganyicz Date: Tue, 11 Aug 2026 23:03:10 +0200 Subject: [PATCH 55/56] Update AttributeTest.php --- tests/Parser/AttributeTest.php | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/Parser/AttributeTest.php b/tests/Parser/AttributeTest.php index 2ae130fe..043ae7f8 100644 --- a/tests/Parser/AttributeTest.php +++ b/tests/Parser/AttributeTest.php @@ -2,7 +2,6 @@ use Illuminate\Support\Arr; use Livewire\Blaze\Support\AttributeParser; -use Illuminate\Support\Arr; test('getStaticValue returns constatnts for dynamic constant values', function () { $attribute = app(AttributeParser::class)->parse(':foo="true"')['foo']; From 80d2750d47621636390ed7d7f39fd1ead22036c0 Mon Sep 17 00:00:00 2001 From: Filip Ganyicz Date: Wed, 12 Aug 2026 02:03:01 +0200 Subject: [PATCH 56/56] Fix failing tests --- tests/BladeRendererTest.php | 12 ++++++------ tests/Folder/FoldableTest.php | 36 +++++++++++++++++------------------ tests/Folder/FolderTest.php | 2 +- tests/Folder/UnblazeTest.php | 6 +++--- 4 files changed, 28 insertions(+), 28 deletions(-) diff --git a/tests/BladeRendererTest.php b/tests/BladeRendererTest.php index c8088933..c667a32a 100644 --- a/tests/BladeRendererTest.php +++ b/tests/BladeRendererTest.php @@ -10,7 +10,7 @@ test('compiles component source into the temporary cache', function () { $path = fixture_path('views/components/foldable/input.blade.php'); - $node = app(Parser::class)->parse('')[0]; + $node = app(Parser::class)->parse('')->nodes[0]; app(BladeRenderer::class)->render($node, $path); @@ -18,7 +18,7 @@ }); test('makes attributes available to aware props', function () { - $node = app(Parser::class)->parse('')[0]; + $node = app(Parser::class)->parse('')->nodes[0]; $output = app(BladeRenderer::class)->render($node, fixture_path('views/components/foldable/input-aware.blade.php')); @@ -26,7 +26,7 @@ }); test('makes slots available to aware props', function () { - $node = app(Parser::class)->parse('number')[0]; + $node = app(Parser::class)->parse('number')->nodes[0]; $output = app(BladeRenderer::class)->render($node, fixture_path('views/components/foldable/input-aware.blade.php')); @@ -34,7 +34,7 @@ }); test('makes parents attributes available to aware props', function () { - $node = app(Parser::class)->parse('')[0]; + $node = app(Parser::class)->parse('')->nodes[0]; $node->setParentsAttributes( app(AttributeParser::class)->parse('type="number"') @@ -46,7 +46,7 @@ }); test('processes unblaze blocks', function () { - $node = app(Parser::class)->parse('')[0]; + $node = app(Parser::class)->parse('')->nodes[0]; $output = app(BladeRenderer::class)->render($node, fixture_path('views/components/foldable/input-unblaze.blade.php')); @@ -59,7 +59,7 @@ }); test('deletes the temporary cache directory', function () { - $node = app(Parser::class)->parse('')[0]; + $node = app(Parser::class)->parse('')->nodes[0]; app(BladeRenderer::class)->render($node, fixture_path('views/components/foldable/input.blade.php')); diff --git a/tests/Folder/FoldableTest.php b/tests/Folder/FoldableTest.php index 7def932b..25ba1597 100644 --- a/tests/Folder/FoldableTest.php +++ b/tests/Folder/FoldableTest.php @@ -10,7 +10,7 @@ use function Pest\Laravel\mock; test('replaces and restores bound attributes', function () { - $node = app(Parser::class)->parse('')[0]; + $node = app(Parser::class)->parse('')->nodes[0]; mock(BladeRenderer::class) ->expects('render') @@ -27,7 +27,7 @@ }); test('preserves bound attributes with static constant values', function (string $value) { - $node = app(Parser::class)->parse('')[0]; + $node = app(Parser::class)->parse('')->nodes[0]; mock(BladeRenderer::class) ->expects('render') @@ -42,7 +42,7 @@ })->with(['false', 'true', 'null']); test('replaces parents attributes', function () { - $node = app(Parser::class)->parse('')[0]; + $node = app(Parser::class)->parse('')->nodes[0]; $node->setParentsAttributes( app(AttributeParser::class)->parse(':type="$type"') @@ -64,7 +64,7 @@ }); test('restores every occurrence of a dynamic attribute placeholder', function () { - $node = app(Parser::class)->parse('')[0]; + $node = app(Parser::class)->parse('')->nodes[0]; mock(BladeRenderer::class) ->expects('render') @@ -83,7 +83,7 @@ }); test('restores bound attributes inside php blocks as raw expressions', function () { - $node = app(Parser::class)->parse('')[0]; + $node = app(Parser::class)->parse('')->nodes[0]; mock(BladeRenderer::class) ->expects('render') @@ -100,7 +100,7 @@ }); test('compiles echo attributes restored inside php blocks', function () { - $node = app(Parser::class)->parse('')[0]; + $node = app(Parser::class)->parse('')->nodes[0]; mock(BladeRenderer::class) ->expects('render') @@ -117,7 +117,7 @@ }); test('compiles bound attributes passed through attribute bag', function () { - $node = app(Parser::class)->parse('')[0]; + $node = app(Parser::class)->parse('')->nodes[0]; mock(BladeRenderer::class) ->expects('render') @@ -140,7 +140,7 @@ }); test('restores unbound attributes passed through attribute bag', function () { - $node = app(Parser::class)->parse('')[0]; + $node = app(Parser::class)->parse('')->nodes[0]; mock(BladeRenderer::class) ->expects('render') @@ -157,7 +157,7 @@ }); test('uses empty strings for true x-data and wire: attributes passed through attribute bag', function (string $attribute) { - $node = app(Parser::class)->parse('')[0]; + $node = app(Parser::class)->parse('')->nodes[0]; mock(BladeRenderer::class) ->expects('render') @@ -180,7 +180,7 @@ })->with(['x-data', 'wire:loading']); test('handles newlines consumed by attribute php blocks', function () { - $node = app(Parser::class)->parse('')[0]; + $node = app(Parser::class)->parse('')->nodes[0]; mock(BladeRenderer::class) ->expects('render') @@ -217,7 +217,7 @@ BLADE; - $node = app(Parser::class)->parse($input)[0]; + $node = app(Parser::class)->parse($input)->nodes[0]; mock(BladeRenderer::class) ->expects('render') @@ -266,7 +266,7 @@ BLADE; - $node = app(Parser::class)->parse($input)[0]; + $node = app(Parser::class)->parse($input)->nodes[0]; mock(BladeRenderer::class) ->expects('render') @@ -283,7 +283,7 @@ }); test('handles newlines consumed by slot php blocks', function () { - $node = app(Parser::class)->parse('Content')[0]; + $node = app(Parser::class)->parse('Content')->nodes[0]; mock(BladeRenderer::class) ->expects('render') @@ -300,7 +300,7 @@ }); test('wraps output with aware macros if descendants use aware', function () { - $node = app(Parser::class)->parse('')[0]; + $node = app(Parser::class)->parse('')->nodes[0]; $node->hasAwareDescendants = true; @@ -323,7 +323,7 @@ }); test('compiles dynamic attributes in aware macros', function () { - $node = app(Parser::class)->parse('')[0]; + $node = app(Parser::class)->parse('')->nodes[0]; $node->hasAwareDescendants = true; @@ -346,7 +346,7 @@ }); test('compiles echo attributes in aware macros', function () { - $node = app(Parser::class)->parse('')[0]; + $node = app(Parser::class)->parse('')->nodes[0]; $node->hasAwareDescendants = true; mock(BladeRenderer::class) @@ -368,7 +368,7 @@ }); test('does not add aware macros to components without attributes', function () { - $node = app(Parser::class)->parse('')[0]; + $node = app(Parser::class)->parse('')->nodes[0]; $node->hasAwareDescendants = true; mock(BladeRenderer::class) @@ -385,7 +385,7 @@ }); test('does not add aware macros for inherited attributes only', function () { - $node = app(Parser::class)->parse('')[0]; + $node = app(Parser::class)->parse('')->nodes[0]; $node->hasAwareDescendants = true; $node->setParentsAttributes(app(AttributeParser::class)->parse('theme="dark"')); diff --git a/tests/Folder/FolderTest.php b/tests/Folder/FolderTest.php index 8fc41cd0..93648739 100644 --- a/tests/Folder/FolderTest.php +++ b/tests/Folder/FolderTest.php @@ -83,7 +83,7 @@ test('does not fold components with attribute spread from parent', function () { $input = ''; - $node = app(Parser::class)->parse($input)[0]; + $node = app(Parser::class)->parse($input)->nodes[0]; $node->setParentsAttributes( app(AttributeParser::class)->parse(':attributes="$attributes"') diff --git a/tests/Folder/UnblazeTest.php b/tests/Folder/UnblazeTest.php index 9f98d446..8c969a0e 100644 --- a/tests/Folder/UnblazeTest.php +++ b/tests/Folder/UnblazeTest.php @@ -12,7 +12,7 @@ test('compiles unblaze blocks', function () { $input = ''; - $node = app(Parser::class)->parse($input)[0]; + $node = app(Parser::class)->parse($input)->nodes[0]; $foldable = new Foldable($node, fixture_path('views/components/foldable/input-unblaze.blade.php'), app(BladeRenderer::class), app(BladeService::class)); expect($foldable->fold())->toEqualCollapsingWhitespace( @@ -28,7 +28,7 @@ test('compiles nested unblaze blocks', function () { $input = ''; - $node = app(Parser::class)->parse($input)[0]; + $node = app(Parser::class)->parse($input)->nodes[0]; $foldable = new Foldable($node, fixture_path('views/components/foldable/nested-input-unblaze.blade.php'), app(BladeRenderer::class), app(BladeService::class)); expect($foldable->fold())->toEqualCollapsingWhitespace( @@ -44,7 +44,7 @@ test('folds dynamic attributes used inside unblaze directive', function () { $input = ''; - $node = app(Parser::class)->parse($input)[0]; + $node = app(Parser::class)->parse($input)->nodes[0]; $foldable = new Foldable($node, fixture_path('views/components/foldable/input-unblaze.blade.php'), app(BladeRenderer::class), app(BladeService::class)); expect($foldable->fold())->toEqualCollapsingWhitespace(