diff --git a/src/BladeService.php b/src/BladeService.php index 75a9a4c9..2674e777 100644 --- a/src/BladeService.php +++ b/src/BladeService.php @@ -210,32 +210,6 @@ public static function preStoreUncompiledBlocks(string $input): string return $storeVerbatimBlocks->invoke($compiler, $input); } - /** - * Store only @verbatim blocks as raw block placeholders. - */ - public static function storeVerbatimBlocks(string $input): string - { - $compiler = app('blade.compiler'); - - $reflection = new \ReflectionClass($compiler); - $method = $reflection->getMethod('storeVerbatimBlocks'); - - return $method->invoke($compiler, $input); - } - - /** - * Restore raw block placeholders to their original content. - */ - public static function restoreRawBlocks(string $input): string - { - $compiler = app('blade.compiler'); - - $reflection = new \ReflectionClass($compiler); - $method = $reflection->getMethod('restoreRawContent'); - - return $method->invoke($compiler, $input); - } - /** * Invoke the Blade compiler's compileComments via reflection. */ diff --git a/src/Compiler/Wrapper.php b/src/Compiler/Wrapper.php index 472b2621..04f5fc68 100644 --- a/src/Compiler/Wrapper.php +++ b/src/Compiler/Wrapper.php @@ -33,17 +33,18 @@ public function wrap(string $compiled, string $path, ?string $source = null): st $sourceUsesThis = str_contains($source, '$this') || str_contains($compiled, '@entangle') || str_contains($compiled, '@script'); $compiled = BladeService::compileUseStatements($compiled); - $compiled = BladeService::restoreRawBlocks($compiled); - $compiled = BladeService::storeVerbatimBlocks($compiled); + + // Keep @php/@verbatim content as @__raw_block_N__@ placeholders so + // downstream precompilers can't match/modify patterns inside them. + // Laravel's restoreRawContent() handles restoration at the end of + // compileString(). $imports = ''; - + $compiled = $this->useExtractor->extract($compiled, function ($statement) use (&$imports) { $imports .= $statement . "\n"; }); - $compiled = BladeService::preStoreUncompiledBlocks($compiled); - $output = ''; $output .= '<'.'?php' . "\n"; diff --git a/src/Parser/Tokenizer.php b/src/Parser/Tokenizer.php index 458ca871..35978d9a 100644 --- a/src/Parser/Tokenizer.php +++ b/src/Parser/Tokenizer.php @@ -52,22 +52,43 @@ class Tokenizer /** * Tokenize a Blade template into an array of tokens. */ - public function tokenize(string $content): array + public function tokenize(string $template): array { - $this->resetTokenizer($content); + $this->tokens = []; + $this->buffer = ''; + $this->currentToken = null; + $this->tagStack = []; + $this->currentPrefix = ''; + $this->currentSlotPrefix = ''; $state = TokenizerState::TEXT; - 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(), - default => throw new \RuntimeException("Unknown state: $state"), - }; + 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(), + 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; + + $state = TokenizerState::TEXT; + + $this->buffer .= is_array($token) ? $token[1] : $token; + } } $this->flushBuffer(); @@ -75,22 +96,6 @@ public function tokenize(string $content): array return $this->tokens; } - /** - * Reset all tokenizer state for a new tokenization pass. - */ - protected function resetTokenizer(string $content): void - { - $this->content = $content; - $this->position = 0; - $this->length = strlen($content); - $this->tokens = []; - $this->buffer = ''; - $this->currentToken = null; - $this->tagStack = []; - $this->currentPrefix = ''; - $this->currentSlotPrefix = ''; - } - /** * Process text state, detecting component/slot tag boundaries. */ @@ -99,9 +104,9 @@ protected function handleTextState(): TokenizerState $char = $this->current(); if ($char === '<') { - if ($slotInfo = $this->matchSlotOpen()) { - $this->flushBuffer(); + $this->flushBuffer(); + if ($slotInfo = $this->matchSlotOpen()) { $this->currentSlotPrefix = $slotInfo['prefix']; if ($slotInfo['isShort']) { @@ -116,8 +121,6 @@ protected function handleTextState(): TokenizerState } if ($slotInfo = $this->matchSlotClose()) { - $this->flushBuffer(); - $this->currentToken = new SlotCloseToken(); $this->currentSlotPrefix = $slotInfo['prefix']; @@ -130,8 +133,6 @@ protected function handleTextState(): TokenizerState } if ($prefixInfo = $this->matchComponentOpen()) { - $this->flushBuffer(); - $this->currentPrefix = $prefixInfo['prefix']; $this->currentToken = new TagOpenToken( @@ -144,8 +145,6 @@ protected function handleTextState(): TokenizerState } if ($this->peek(1) === '/' && ($prefixInfo = $this->matchComponentClose())) { - $this->flushBuffer(); - $this->currentPrefix = $prefixInfo['prefix']; $this->currentToken = new TagCloseToken( @@ -158,8 +157,6 @@ protected function handleTextState(): TokenizerState } } - $this->buffer .= $char; - $this->advance(); return TokenizerState::TEXT; @@ -189,18 +186,18 @@ protected function handleTagOpenState(): TokenizerState array_pop($this->tagStack); - $this->tokens[] = $this->currentToken; - $this->advance(2); + $this->emitToken(); + return TokenizerState::TEXT; } if ($this->current() === '>') { - $this->tokens[] = $this->currentToken; - $this->advance(); + $this->emitToken(); + return TokenizerState::TEXT; } } @@ -224,10 +221,10 @@ protected function handleTagCloseState(): TokenizerState } if ($this->current() === '>') { - $this->tokens[] = $this->currentToken; - $this->advance(); + $this->emitToken(); + return TokenizerState::TEXT; } @@ -257,10 +254,10 @@ protected function handleSlotOpenState(): TokenizerState } if ($this->current() === '>') { - $this->tokens[] = $this->currentToken; - $this->advance(); + $this->emitToken(); + return TokenizerState::TEXT; } @@ -281,10 +278,10 @@ protected function handleSlotCloseState(): TokenizerState } if ($this->current() === '>') { - $this->tokens[] = $this->currentToken; - $this->advance(); + $this->emitToken(); + return TokenizerState::TEXT; } @@ -306,10 +303,10 @@ protected function handleShortSlotState(): TokenizerState $this->collectAttributes(); if ($this->current() === '>') { - $this->tokens[] = $this->currentToken; - $this->advance(); + $this->emitToken(); + return TokenizerState::TEXT; } } @@ -525,6 +522,8 @@ protected function remaining(): string */ protected function advance(int $count = 1): void { + $this->buffer .= substr($this->content, $this->position, $count); + $this->position += $count; } @@ -536,6 +535,16 @@ protected function isAtEnd(): bool return $this->position >= $this->length; } + /** + * Emit the current token and discard the raw buffer. + */ + protected function emitToken(): void + { + $this->tokens[] = $this->currentToken; + + $this->buffer = ''; + } + /** * Emit any accumulated text buffer as a TextToken. */ diff --git a/tests/Compiler/WrapperTest.php b/tests/Compiler/WrapperTest.php index 1ffe79ae..5097c740 100644 --- a/tests/Compiler/WrapperTest.php +++ b/tests/Compiler/WrapperTest.php @@ -93,9 +93,12 @@ }); test('hoists use statements to top of output', function ($statement) { - $source = "{$statement}\n
"; + // Replace raw @php blocks for placeholders. This normally happens in BlazeManager before the template gets to the Wrapper + $source = BladeService::preStoreUncompiledBlocks($statement); - expect(app(Wrapper::class)->wrap($source, '', $source))->toStartWith("wrap($source, '', $source))->toStartWith("with([ + ['@use(\'App\Models\User\')'], ['@php use \App\Models\User; @endphp'], + [''], ]); \ No newline at end of file diff --git a/tests/IntegrationTest.php b/tests/IntegrationTest.php index 61ede7f8..ab7c8a9a 100644 --- a/tests/IntegrationTest.php +++ b/tests/IntegrationTest.php @@ -20,6 +20,94 @@ expect(Blade::render($input))->toBe(''); }); +test('component inside PHP line comment is not compiled by Blaze', function () { + $compiled = compile('php-comment-parent.blade.php'); + + expect($compiled)->not->toContain('ensureCompiled'); + expect($compiled)->not->toContain('$__blaze->pushData'); + expect($compiled)->toContain('visible'); +}); + +test('component inside Blade comment is correctly ignored', function () { + $output = blade( + view: <<<'BLADE' +{{-- --}} +
visible
+BLADE, + components: [ + 'mycomp' => <<<'BLADE' +@blaze +SHOULD NOT APPEAR +BLADE, + ], + ); + + expect($output)->toContain('visible'); + expect($output)->not->toContain('SHOULD NOT APPEAR'); +}); + +test('component inside @php block is correctly ignored', function () { + $output = blade( + view: <<<'BLADE' +@php // @endphp +
visible
+BLADE, + components: [ + 'mycomp' => <<<'BLADE' +@blaze +SHOULD NOT APPEAR +BLADE, + ], + ); + + expect($output)->toContain('visible'); + expect($output)->not->toContain('SHOULD NOT APPEAR'); +}); + +test('component inside PHP block comment renders correctly end-to-end', function () { + $output = blade( + view: <<<'BLADE' + */ ?> +
visible
+BLADE, + components: [ + 'mycomp' => <<<'BLADE' +@blaze +SHOULD NOT APPEAR +BLADE, + ], + ); + + expect($output)->toContain('visible'); + expect($output)->not->toContain('SHOULD NOT APPEAR'); +}); + +test('protects php blocks from precompilers that inject php tags', function () { + // Simulate Livewire's SupportMorphAwareBladeCompilation precompiler which + // wraps morph markers in PHP open/close tags. The injected close-tag + // terminates PHP mode even inside // comments, causing @if to be + // compiled as a bare directive. + app('blade.compiler')->precompiler(function ($template) { + $open = '<' . '?php'; + $close = '?' . '>'; + $prefix = $open . ' if(true): ' . $close . '' . $open . ' endif; ' . $close; + + return preg_replace( + '/(?not->toContain('<' . '?php if: ?' . '>'); +}); + // TODO: Install PHPStan, which probably would have caught this. test('supports php engine', function () { // Make sure our hooks do not break views diff --git a/tests/Parser/TokenizerTest.php b/tests/Parser/TokenizerTest.php index ef866903..89d2c347 100644 --- a/tests/Parser/TokenizerTest.php +++ b/tests/Parser/TokenizerTest.php @@ -6,6 +6,7 @@ use Livewire\Blaze\Parser\Tokens\TagCloseToken; use Livewire\Blaze\Parser\Tokens\TagOpenToken; use Livewire\Blaze\Parser\Tokens\TagSelfCloseToken; +use Livewire\Blaze\Parser\Tokens\TextToken; test('tokenizes tags', function () { $input = ''; @@ -101,12 +102,44 @@ expect($result)->toEqual([ new TagOpenToken( - name: 'button', - prefix: 'x-', + name: 'button', + prefix: 'x-', attributes: [ ':data="[\'foo\' => \'bar\']"', ':callback="fn () => 0"', ], ), ]); +}); + +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: ''), + ]); +}); + +test('handles php blocks inside tags', function () { + $input = '>'; + + $result = app(Tokenizer::class)->tokenize($input); + + expect($result)->toEqual([ + new TextToken(content: '>'), + ]); }); \ No newline at end of file diff --git a/tests/fixtures/components/php-comment-child.blade.php b/tests/fixtures/components/php-comment-child.blade.php new file mode 100644 index 00000000..7105f128 --- /dev/null +++ b/tests/fixtures/components/php-comment-child.blade.php @@ -0,0 +1,3 @@ +@blaze + +SHOULD NOT APPEAR diff --git a/tests/fixtures/components/php-comment-parent.blade.php b/tests/fixtures/components/php-comment-parent.blade.php new file mode 100644 index 00000000..e0c007da --- /dev/null +++ b/tests/fixtures/components/php-comment-parent.blade.php @@ -0,0 +1,4 @@ +@blaze + + ?> +
visible
diff --git a/tests/fixtures/components/php-comment-with-directive.blade.php b/tests/fixtures/components/php-comment-with-directive.blade.php new file mode 100644 index 00000000..0e76c8f9 --- /dev/null +++ b/tests/fixtures/components/php-comment-with-directive.blade.php @@ -0,0 +1,10 @@ +@blaze + +@props([]) + +@php + // This comment mentions @if but should not be compiled as a directive + $value = 'hello'; +@endphp + +
{{ $value }}