diff --git a/src/BladeRenderer.php b/src/BladeRenderer.php index 1303f8a2..202b2e9f 100644 --- a/src/BladeRenderer.php +++ b/src/BladeRenderer.php @@ -6,7 +6,6 @@ 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; @@ -70,13 +69,7 @@ public function render(ComponentNode $component, string $path): string '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/BladeService.php b/src/BladeService.php index 31af937a..3d0cc70e 100644 --- a/src/BladeService.php +++ b/src/BladeService.php @@ -7,14 +7,15 @@ 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 { protected ComponentTagCompiler $tagCompiler; + + protected ReflectionClass $compilerReflection; + protected ReflectionClass $tagCompilerReflection; protected ?array $customConditions = null; @@ -27,6 +28,9 @@ public function __construct( $compiler->getClassComponentNamespaces(), $compiler, ); + + $this->compilerReflection = new ReflectionClass($this->compiler); + $this->tagCompilerReflection = new ReflectionClass($this->tagCompiler); } /** @@ -49,77 +53,12 @@ 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. */ 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); } @@ -129,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); } @@ -162,14 +100,11 @@ 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'); + $method = $this->compilerReflection->getMethod('compileUse'); - return $method->invoke($this->compiler, $expression); - })->compile($input); + return $method->invoke($this->compiler, $expression); } /** @@ -181,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(); } @@ -212,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)."'", "''.", ".''"); } @@ -271,6 +204,16 @@ public function componentNameToPath($name): string return ''; } + /** + * Check if the Blade compiler has any echo handlers registered. + */ + public function hasEchoHandlers(): bool + { + $handlers = $this->compilerReflection->getProperty('echoHandlers')->getValue($this->compiler); + + return ! empty($handlers); + } + /** * Determine if a component resolves to a class rather than a blade view. * @@ -301,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); } diff --git a/src/BlazeManager.php b/src/BlazeManager.php index 2ed5db99..bad6747c 100644 --- a/src/BlazeManager.php +++ b/src/BlazeManager.php @@ -5,23 +5,26 @@ 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\Debugger\Instrumenter; -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; +use Livewire\Blaze\Parser\Nodes\CompiledBlockNode; 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; +use Livewire\Blaze\Parser\Nodes\Node; +use Livewire\Blaze\Parser\Nodes\TextNode; +use Illuminate\View\Factory; class BlazeManager { @@ -34,8 +37,6 @@ class BlazeManager protected $foldedEvents = []; protected $expiredMemo = []; - protected Parser $parser; - protected Walker $walker; protected Compiler $compiler; protected Folder $folder; protected Memoizer $memoizer; @@ -48,15 +49,16 @@ public function __construct( protected BladeCompiler $bladeCompiler, protected BlazeRuntime $runtime, protected BladeService $blade, + protected Factory $factory, + protected ComponentRepository $components, + protected Parser $parser, ) { - $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->renderer = new BladeRenderer($bladeCompiler, $factory, $this->runtime, $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 Instrumenter($config, $this->blade); + $this->instrumenter = new Instrumenter($config, $this->blade, $this, $this->components); Event::listen(ComponentFolded::class, function (ComponentFolded $event) { $this->foldedEvents[] = $event; @@ -66,18 +68,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 { - $source = $template; - - $clean = $template; - $clean = $this->blade->preStoreUncompiledBlocks($clean); - $clean = $this->blade->compileComments($clean); - $dataStack = []; - $ast = $this->walker->walk( - nodes: $this->parser->parse($clean), + $template = $this->parser->parse($source, $path); + + $ast = Walker::walk( + nodes: $template->nodes, preCallback: function ($node) use (&$dataStack) { if ($node instanceof ComponentNode && $node->children) { $dataStack[] = $node->attributes; @@ -117,15 +115,68 @@ public function compile(string $template, ?string $path = null): string $output = $this->render($ast); - $directives = new Directives($source); - - if ($path && ($directives->blaze() || $this->config->shouldCompile($path))) { - $output = $this->wrapper->wrap($output, $path, $source); + 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, $source); } - $output = $this->blade->restoreRawBlocks($output); + 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 = Walker::walk( + nodes: $template->nodes, + preCallback: function (Node $node) use (&$currentUnblazeToken) { + if ($node instanceof DirectiveNode && $node->is('unblaze')) { + $currentUnblazeToken = str()->random(10); + $tag = '[STARTCOMPILEDUNBLAZE:' . $currentUnblazeToken . ']'; + $content = 'expression . '); ?>'; + + return new CompiledBlockNode($tag . $content); + } + + if ($node instanceof DirectiveNode && $node->is('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; } @@ -133,13 +184,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->blade->preStoreUncompiledBlocks($template); - $template = $this->blade->compileComments($template); + $template = $this->parser->parse($source); - $ast = $this->walker->walk( - nodes: $this->parser->parse($template), + $ast = Walker::walk( + nodes: $template->nodes, preCallback: fn ($node) => $node, postCallback: function ($node) { $wasComponent = $node instanceof ComponentNode; @@ -156,13 +206,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); } /** @@ -172,16 +216,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; - - $clean = $template; - $clean = $this->blade->preStoreUncompiledBlocks($clean); - $clean = $this->blade->compileComments($clean); + $template = $this->parser->parse($source, $path); - $ast = $this->walker->walk( - nodes: $this->parser->parse($clean), + $ast = Walker::walk( + nodes: $template->nodes, preCallback: fn ($node) => $node, postCallback: function ($node) { if (! ($node instanceof ComponentNode)) { @@ -198,47 +238,6 @@ public function compileForDebug(string $template, ?string $path = null): string $output = $this->instrumenter->profileView($output, $path, $source); } - $output = $this->blade->restoreRawBlocks($output); - - return $output; - } - - /** - * 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 - { - $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, - postCallback: function ($node) { - return $this->compiler->compile($node); - }, - ); - - $output = $this->render($ast); - - $output = $this->blade->restoreRawBlocks($output); - - if (! $path) { - return $output; - } - - $directives = new Directives($source); - $shouldWrap = $this->config->shouldFold($path) - || $this->config->shouldMemoize($path) - || $this->config->shouldCompile($path); - - if ($directives->blaze() || $shouldWrap) { - $output = $this->wrapper->wrap($output, $path, $source); - } - return $output; } @@ -257,11 +256,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() @@ -273,7 +272,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(); @@ -404,13 +403,13 @@ protected function hasAwareDescendant(ComponentNode|SlotNode $node): bool { foreach ($node->children as $child) { if ($child instanceof ComponentNode) { - $source = ComponentSource::for($this->blade->componentNameToPath($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/BlazeServiceProvider.php b/src/BlazeServiceProvider.php index 5f586471..df3ca860 100644 --- a/src/BlazeServiceProvider.php +++ b/src/BlazeServiceProvider.php @@ -14,6 +14,8 @@ 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 { @@ -27,6 +29,8 @@ public function register(): void $this->app->singleton(Debugger::class); $this->app->singleton(Instrumenter::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(); @@ -195,12 +199,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/Compiler/Compiler.php b/src/Compiler/Compiler.php index 429937f6..8dfcf8d4 100644 --- a/src/Compiler/Compiler.php +++ b/src/Compiler/Compiler.php @@ -4,11 +4,12 @@ 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; 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); } @@ -37,16 +39,16 @@ public function compile(Node $node): Node } if ($node->name === 'flux::delegate-component') { - return new TextNode($this->compileDelegateComponentTag($node)); + return new CompiledBlockNode($this->compileDelegateComponentTag($node)); } - $source = ComponentSource::for($this->blade->componentNameToPath($node->name)); + $component = $this->components->get($node->name); - if (! $source->exists()) { + if (! $component) { return $node; } - if (! $this->shouldCompile($source)) { + if (! $this->shouldCompile($component)) { return $node; } @@ -54,7 +56,7 @@ public function compile(Node $node): Node return $node; } - return new TextNode($this->compileComponentTag($node, $source)); + return new CompiledBlockNode($this->compileComponentTag($node, $component)); } /** @@ -62,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) @@ -73,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; } } @@ -92,7 +92,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/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/Compiler/UseExtractor.php b/src/Compiler/UseExtractor.php index f9c8900a..07456a17 100644 --- a/src/Compiler/UseExtractor.php +++ b/src/Compiler/UseExtractor.php @@ -11,6 +11,13 @@ */ class UseExtractor { + protected Parser $parser; + + public function __construct() + { + $this->parser = app(Parser::class); + } + /** * Extract use statements from blocks in the compiled template. * @@ -25,7 +32,7 @@ public function extract(string $compiled, callable $callback): string $block = 'parse($block); + $ast = $this->parser->parse($block); } catch (\Throwable) { return $match[0]; } diff --git a/src/Compiler/Wrapper.php b/src/Compiler/Wrapper.php index 12484ec3..6440778f 100644 --- a/src/Compiler/Wrapper.php +++ b/src/Compiler/Wrapper.php @@ -4,9 +4,12 @@ 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\EchoNode; +use Livewire\Blaze\Parser\Nodes\PhpBlockNode; +use Livewire\Blaze\Parser\Walker; +use Livewire\Blaze\Compiler\UseExtractor; /** * Compiles Blaze component templates into PHP function definitions. @@ -29,120 +32,109 @@ 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 = false; $imports = ''; - - $compiled = $this->useExtractor->extract($compiled, function ($statement) use (&$imports) { - $imports .= $statement . "\n"; - }); - - $compiled = $this->blade->preStoreUncompiledBlocks($compiled); - - $output = ''; - - $output .= '<'.'?php' . "\n"; - $output .= $imports; - $output .= 'if (!function_exists(\''.$name.'\')):'."\n"; - $output .= 'function '.$name.'($__blaze, $__data = [], $__slots = [], $__bound = [], $__keys = [], $__this = null) {'."\n"; - - if ($sourceUsesThis) { - $output .= '$__blazeFn = function () use ($__blaze, $__data, $__slots, $__bound, $__keys) {'."\n"; - } - - $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"; - $compiled = DirectiveCompiler::make() - ->directive('props', $this->propsCompiler->compile(...)) - ->directive('aware', $this->awareCompiler->compile(...)) - ->compile($compiled); - - $compiled = $this->blade->restoreRawBlocks($compiled); - - $output .= $compiled; - - $output .= 'containsPhp('$this') || $node->isDirective(['entangle', 'script', 'assets']))) { + $sourceUsesThis = true; + } - $contentHandler = $this->manager->isFolding() ? '$__blaze->processPassthroughContent(\'ltrim\', ltrim(ob_get_clean()))' : 'ltrim(ob_get_clean())'; + if ($node instanceof DirectiveNode && $node->is('use')) { + return new PhpBlockNode($this->blade->compileUseStatements($node->expression)); + } - $output .= 'echo ' . $contentHandler . ';' . "\n"; + 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"; + }) + ); + } - if ($sourceUsesThis) { - $output .= '}; if ($__this !== null) { $__blazeFn->call($__this); } else { $__blazeFn(); }'."\n"; - } + if ($node instanceof DirectiveNode && $node->is('props')) { + return new PhpBlockNode($this->propsCompiler->compile($node->expression)); + } - $output .= '} endif; ?>'; + if ($node instanceof DirectiveNode && $node->is('aware')) { + return new PhpBlockNode($this->awareCompiler->compile($node->expression)); + } - return $output; + return $node; + } + ); + + $opening = '<'.'?php'."\n"; + $opening .= $imports; + $opening .= 'if (!function_exists(\''.$name.'\')):'."\n"; + $opening .= 'function '.$name.'($__blaze, $__data = [], $__slots = [], $__bound = [], $__keys = [], $__this = null) {'."\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"; + $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"; + + $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; ?>'; + + 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->blade->hasEchoHandlers(); - if ($this->hasEchoHandlers() && ($this->hasEchoSyntax($source) || $this->hasEchoSyntax($compiled))) { - $output .= '$__bladeCompiler = app(\'blade.compiler\');' . "\n"; - } + foreach (Walker::iterate($ast) as $node) { + if (! isset($variables['$app']) && $node->containsPhp('$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->containsPhp('$errors') || $node->isDirective('error'))) { + $variables['$errors'] = '$errors = $__blaze->errors;'; } - return null; - }))) . "\n"; + if (! isset($variables['$__livewire']) && ($node->containsPhp('$__livewire') || $node->isDirective('entangle') || $node->isDirective('this'))) { + $variables['$__livewire'] = '$__livewire = $__env->shared(\'__livewire\');'; + } - return $output; - } + if (! isset($variables['$_instance']) && $node->isDirective('this')) { + $variables['$_instance'] = '$_instance = $__livewire;'; + } - /** - * Check if the Blade compiler has any echo handlers registered. - */ - protected function hasEchoHandlers(): bool - { - $compiler = $this->blade->compiler; - $reflection = new \ReflectionProperty($compiler, 'echoHandlers'); + if (! isset($variables['$slot']) && $node->containsPhp('$slot')) { + $variables['$slot'] = '$__slots[\'slot\'] ??= new \Illuminate\View\ComponentSlot(\'\');'; + } - return ! empty($reflection->getValue($compiler)); - } + if (! isset($variables['$__bladeCompiler']) && $hasEchoHandlers && $node instanceof EchoNode) { + $variables['$__bladeCompiler'] = '$__bladeCompiler = app(\'blade.compiler\');'; + } + } - /** - * Check if the source contains Blade echo syntax. - */ - protected function hasEchoSyntax(string $source): bool - { - return preg_match('/\{\{.+?\}\}|\{!!.+?!!\}/s', $source) === 1; + return join("\n", $variables); } } diff --git a/src/Debugger/Instrumenter.php b/src/Debugger/Instrumenter.php index 22c5066c..a03e0d71 100644 --- a/src/Debugger/Instrumenter.php +++ b/src/Debugger/Instrumenter.php @@ -3,11 +3,12 @@ namespace Livewire\Blaze\Debugger; 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\ComponentSource; +use Livewire\Blaze\Support\ComponentRepository; /** * Wraps every component's compiled output with profiler timer calls. @@ -23,6 +24,8 @@ class Instrumenter public function __construct( protected Config $config, protected BladeService $blade, + protected BlazeManager $manager, + protected ComponentRepository $components, ) { } @@ -31,14 +34,14 @@ public function __construct( */ public function profile(Node $node, string $componentName, ?string $strategy = null): Node { - $source = ComponentSource::for($this->blade->componentNameToPath($componentName)); + $source = $this->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); @@ -48,21 +51,7 @@ public function profile(Node $node, string $componentName, ?string $strategy = n .$output .'<'.'?php $__blaze->debugger->stopTimer(\''.$escapedName.'\'); ?>'; - 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'; + return new CompiledBlockNode($wrapped); } /** diff --git a/src/Folder/Folder.php b/src/Folder/Folder.php index 8643bc82..bea8747a 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; @@ -16,8 +16,10 @@ 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; /** * Determines whether a component should be folded and orchestrates the folding process. @@ -29,6 +31,7 @@ public function __construct( protected BladeService $blade, protected BladeRenderer $renderer, protected BlazeManager $manager, + protected ComponentRepository $components, ) { } @@ -41,36 +44,34 @@ public function fold(Node $node): Node return $node; } - $component = $node; + $component = $this->components->get($node->name); - $source = ComponentSource::for($this->blade->componentNameToPath($component->name)); - - if (! $source->exists()) { - 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->path, $this->renderer, $this->blade); + $foldable = new Foldable($node, $component->path, $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 . ''); + return new CompiledBlockNode('' . $html . ''); } catch (Throwable $th) { if ($this->manager->shouldThrow()) { throw $th; @@ -85,7 +86,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); @@ -109,7 +110,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() @@ -124,17 +125,17 @@ 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; } } } - $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; @@ -220,23 +221,55 @@ protected function slotHasDynamicAttributes(SlotNode $slot): bool */ protected function checkProblematicPatterns(ComponentSource $source): void { - // @unblaze blocks can contain dynamic content and are excluded from validation - $sourceWithoutUnblaze = preg_replace('/@unblaze.*?@endunblaze/s', '', $source->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/Memoizer/Memoizer.php b/src/Memoizer/Memoizer.php index c8aaee51..b1342461 100644 --- a/src/Memoizer/Memoizer.php +++ b/src/Memoizer/Memoizer.php @@ -4,12 +4,12 @@ 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\Support\ComponentSource; use Livewire\Blaze\Compiler\Compiler; +use Livewire\Blaze\Support\ComponentRepository; /** * Wraps compiled component output with runtime memoization logic. @@ -21,6 +21,7 @@ public function __construct( protected Compiler $compiler, protected BladeService $blade, protected BlazeManager $manager, + protected ComponentRepository $components, ) { } @@ -65,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); } /** @@ -77,13 +78,13 @@ protected function isMemoizable(Node $node): bool return false; } - $source = ComponentSource::for($this->blade->componentNameToPath($node->name)); + $source = $this->components->get($node->name); - if (! $source->exists()) { + if (! $source) { 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/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/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. */ 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); + } } 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 bc5f3462..e4dbbe1d 100644 --- a/src/Parser/Nodes/Node.php +++ b/src/Parser/Nodes/Node.php @@ -11,4 +11,43 @@ abstract class Node * Render this node to its string output. */ abstract public function render(): string; + + public function containsPhp(string $php): bool + { + if ($this instanceof PhpBlockNode || $this instanceof CompiledBlockNode) { + 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; + } + + if ($this instanceof SlotNode && $this->hasDynamicName() && str_contains($this->name, $php)) { + return true; + } + } + + if ($this instanceof DirectiveNode) { + if (str_contains($this->expression, $php)) { + 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/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/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/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..2cf8feec 100644 --- a/src/Parser/Parser.php +++ b/src/Parser/Parser.php @@ -2,19 +2,20 @@ 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\EchoNode; +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\Tokens\DirectiveToken; -use Livewire\Blaze\Parser\Tokens\SlotCloseToken; -use Livewire\Blaze\Parser\Tokens\SlotOpenToken; +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\TagSelfCloseToken; +use Livewire\Blaze\Parser\Tokens\PhpBlockToken; use Livewire\Blaze\Parser\Tokens\TextToken; +use Livewire\Blaze\Parser\Tokens\VerbatimBlockToken; use Livewire\Blaze\Support\AttributeParser; /** @@ -22,6 +23,8 @@ */ class Parser { + public array $templates = []; + public function __construct( protected Tokenizer $tokenizer, protected AttributeParser $attributes, @@ -31,108 +34,112 @@ 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); 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), + 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), default => throw new \RuntimeException('Unknown token type: ' . get_class($token)) }; } - return $stack->getAst(); - } + $template = new Template($stack->getAst()); - /** - * Handle an opening component tag token. - */ - protected function handleTagOpen(TagOpenToken $token, ParseStack $stack): void - { - $attributeString = implode(' ', $token->attributes); - - $node = new ComponentNode( - name: $token->namespace . $token->name, - prefix: $token->prefix, - attributeString: $attributeString, - children: [], - selfClosing: false, - attributes: $this->attributes->parse($attributeString), - ); + if ($path) { + $this->templates[$path] = $template; + } - $stack->pushContainer($node); + return $template; } /** - * Handle a self-closing component tag token. + * Handle an opening component tag token. */ - protected function handleTagSelfClose(TagSelfCloseToken $token, ParseStack $stack): void + protected function handleOpeningTag(TagOpenToken $token, ParseStack $stack): void { - $attributeString = implode(' ', $token->attributes); + if ($token->isSlot()) { + $this->handleSlotOpen($token, $stack); + + return; + } $node = new ComponentNode( - name: $token->namespace . $token->name, + name: $token->prefix === 'flux:' ? 'flux::' . $token->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(TagCloseToken $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(TagOpenToken $token, ParseStack $stack): void { - $attributeString = implode(' ', $token->attributes); + $short = str_starts_with($token->name, 'slot:'); + + $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'])) { + $nameAttribute = $attributes['name']->dynamic ? $attributes['name'] : null; + $name = $attributes['name']->value; + $attributeString = preg_replace('/(?:^|\s+):?name\s*=\s*(?:"[^"]*"|\'[^\']*\'|[^\s>]+)/', '', $token->attributes, 1); + + unset($attributes['name']); + } $node = new SlotNode( - name: $token->name ?? 'slot', - attributeString: $attributeString, - slotStyle: $token->slotStyle, + name: $name, + attributeString: trim($attributeString), + slotStyle: $short ? 'short' : 'standard', children: [], - prefix: $token->prefix, + prefix: $token->prefix . 'slot', closeHasName: false, - attributes: $this->attributes->parse($attributeString), + attributes: $attributes, + nameAttribute: $nameAttribute, ); $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( @@ -144,6 +151,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. */ @@ -153,4 +168,29 @@ 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); + } + + public function flushState(): void + { + $this->templates = []; + } } diff --git a/src/Parser/Template.php b/src/Parser/Template.php new file mode 100644 index 00000000..08c1866f --- /dev/null +++ b/src/Parser/Template.php @@ -0,0 +1,21 @@ +directives = new Directives( + Walker::filter($nodes, function (Node $node) { + return $node->isDirective(['blaze', 'aware', 'props']); + }) + ); + } +} diff --git a/src/Parser/Tokenizer.php b/src/Parser/Tokenizer.php index f96efbf4..0566aeed 100644 --- a/src/Parser/Tokenizer.php +++ b/src/Parser/Tokenizer.php @@ -4,18 +4,18 @@ 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\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; use Livewire\Blaze\Parser\Tokens\Token; +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 { @@ -24,38 +24,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 +37,176 @@ 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. + * Tokenize a string of inline HTML content. */ - 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 = $this->blade->compileComments($content); + $this->length = strlen($this->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. + * Process the token starting at the current position. */ - 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(); + $this->flushBuffer(); - 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->advance(strlen('@php')); - array_pop($this->tagStack); + if ($this->advanceUntil('@endphp', fn () => $this->matchDirective())) { + $this->advance(strlen('@endphp')); - $this->advance(2); + $this->flushBuffer(PhpBlockToken::class); + } else { + $original = rtrim($match['original']); - $this->emitToken(); + $this->emitToken(new DirectiveToken($match['name'], $original)); - return TokenizerState::TEXT; + $this->rewind($offset + strlen($original)); } - if ($this->current() === '>') { - $this->advance(); - - $this->emitToken(); - - return TokenizerState::TEXT; - } + return; } - $this->advance(); - - return TokenizerState::TAG_OPEN; - } + if ($this->startsWith('@verbatim') && ($match = $this->matchDirective()) && ! $match['expression']) { + $offset = $this->position; - /** - * Process closing tag state, extracting the component name. - */ - protected function handleTagCloseState(): TokenizerState - { - if ($name = $this->matchTagName()) { - $this->currentToken->name = $name; + $this->flushBuffer(); - array_pop($this->tagStack); + $this->advance(strlen('@verbatim')); - $this->advance(strlen($name)); - } + if ($this->advanceUntil('@endverbatim', fn () => $this->matchDirective())) { + $this->advance(strlen('@endverbatim')); - if ($this->current() === '>') { - $this->advance(); + $this->flushBuffer(VerbatimBlockToken::class); + } else { + $this->emitToken(new DirectiveToken($match['name'], $match['original'])); - $this->emitToken(); + $this->rewind($offset + strlen($match['original'])); + } - return TokenizerState::TEXT; + return; } - $this->advance(); - - return TokenizerState::TAG_CLOSE; - } - - /** - * Process standard slot tag state. - */ - protected function handleSlotOpenState(): TokenizerState - { - $this->collectAttributes(); - - // 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]; + if ($this->current() === '{' && $match = $this->matchEcho()) { + if ($this->position > 0 && $this->content[$this->position - 1] === '@') { + $this->advance(strlen($match['original'])); - unset($this->currentToken->attributes[$i]); + return; + } - $this->currentToken->attributes = array_values($this->currentToken->attributes); + $this->flushBuffer(); + $this->advance(strlen($match['original'])); + $this->emitToken(new EchoToken($match['expression'], $match['original'])); - break; - } + return; } - if ($this->current() === '>') { - $this->advance(); + if ($this->current() === '@' && $match = $this->matchDirective()) { + $this->flushBuffer(); + + $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_OPEN; - } + $this->advance(strlen($match['original'])); - /** - * Process closing slot tag state. - */ - protected function handleSlotCloseState(): TokenizerState - { - if ($name = $this->matchSlotName()) { - $this->currentToken->name = $name; + $this->emitToken(new TagOpenToken($match['prefix'], $match['name'], $match['attributes'], $match['original'], $match['selfClosing'])); - $this->advance(strlen($name)); + return; } - if ($this->current() === '>') { - $this->advance(); + if ($this->current() === '<' && $this->peek() === '/' && $match = $this->matchClosingTag()) { + $this->flushBuffer(); - $this->emitToken(); + $this->advance(strlen($match['original'])); - return TokenizerState::TEXT; - } + $this->emitToken(new TagCloseToken($match['prefix'], $match['name'], $match['original'])); - $this->advance(); + return; + } - return TokenizerState::SLOT_CLOSE; + $this->advanceUntilNext('<@{'); } /** - * Process short slot syntax state (). + * Match an executable Blade echo at the current position. */ - protected function handleShortSlotState(): TokenizerState + protected function matchEcho(): ?array { - if ($name = $this->matchSlotName()) { - $this->currentToken->name = $name; - - $this->advance(strlen($name)); - - $this->collectAttributes(); + $remaining = $this->remaining(); - if ($this->current() === '>') { - $this->advance(); - - $this->emitToken(); - - return TokenizerState::TEXT; + foreach (['/^{!!\s*(.+?)\s*!!}/s', '/^{{{\s*(.+?)\s*}}}/s', '/^{{\s*(.+?)\s*}}/s'] as $pattern) { + if (! preg_match($pattern, $remaining, $matches)) { + continue; } - } - $this->advance(); + return [ + 'expression' => $matches[1], + 'original' => $matches[0], + ]; + } - return TokenizerState::SHORT_SLOT; + return null; } /** - * 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. * @@ -411,7 +244,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; } @@ -424,241 +257,149 @@ protected function matchDirective(): ?array } /** - * Collect all attributes on the current token, splitting on unquoted/unbracketed whitespace. - * Stops at > or /> without consuming them. + * Match an opening or self-closing component tag at the current position. */ - 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; - } - - 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 = ''; - } - - $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]; - } + $pattern = "/^<\s*(x[-:]|flux:)([\w\-:.]*)". LaravelRegex::ATTRIBUTES ."(?\/?)>/x"; + + preg_match($pattern, $this->remaining(), $matches); + + if ($matches) { + return [ + 'original' => $matches[0], + 'prefix' => $matches[1], + 'name' => $matches[2], + 'attributes' => ltrim($matches['attributes']), + 'selfClosing' => $matches['selfClosing'] === '/', + ]; } return null; } /** - * Try to match a slot closing tag at the current position. + * Match a closing component 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], + 'prefix' => $matches[1], + 'name' => $matches[2], + ]; } 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. + * Advance until a matching string satisfying the optional condition is found. */ - 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. + * Advance through the next occurrence of any of the given characters. */ - protected function current(): string + protected function advanceUntilNext(string $characters): void { - return $this->isAtEnd() ? '' : $this->content[$this->position]; + $this->advance(strcspn($this->content, $characters, $this->position + 1) + 1); } /** - * Peek at a character at an offset from the current position. + * Determine whether the remaining content starts with the given string. */ - protected function peek(int $offset = 1): string + protected function startsWith(string $str): bool { - $pos = $this->position + $offset; - - return $pos >= $this->length ? '' : $this->content[$pos]; + return substr_compare($this->content, $str, $this->position, strlen($str)) === 0; } /** - * Get the remaining content from the current position. + * Check if the tokenizer has reached the end of input. */ - protected function remaining(): string + protected function isAtEnd(): bool { - return substr($this->content, $this->position); + return $this->position >= $this->length; } /** - * Advance the position by a number of characters. + * Emit the current token and discard the raw buffer. */ - protected function advance(int $count = 1): void + protected function emitToken(Token $token): void { - $this->buffer .= substr($this->content, $this->position, $count); + $this->tokens[] = $token; - $this->position += $count; - } - - /** - * Check if the tokenizer has reached the end of input. - */ - protected function isAtEnd(): bool - { - return $this->position >= $this->length; + $this->buffer = ''; } /** - * Emit the current token and discard the raw buffer. + * Move to a position and discard the accumulated buffer. */ - protected function emitToken(): void + protected function rewind(int $position): void { - $this->tokens[] = $this->currentToken; - + $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 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 index 29bcfebb..5ba08e23 100644 --- a/src/Parser/Tokens/TagCloseToken.php +++ b/src/Parser/Tokens/TagCloseToken.php @@ -8,8 +8,8 @@ class TagCloseToken extends Token { public function __construct( - public string $name, public string $prefix, - public string $namespace = '', + public string $name, + public string $original, ) {} } diff --git a/src/Parser/Tokens/TagOpenToken.php b/src/Parser/Tokens/TagOpenToken.php index 8fb89b85..cdd4c10f 100644 --- a/src/Parser/Tokens/TagOpenToken.php +++ b/src/Parser/Tokens/TagOpenToken.php @@ -3,14 +3,25 @@ namespace Livewire\Blaze\Parser\Tokens; /** - * Represents an opening component tag (). + * Represents an opening or self-closing component tag. */ class TagOpenToken extends Token { public function __construct( - public string $name, public string $prefix, - public string $namespace = '', - public array $attributes = [], + 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/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..d33b0d21 --- /dev/null +++ b/src/Parser/Tokens/VerbatimBlockToken.php @@ -0,0 +1,13 @@ +children)) { - $node->children = $this->walk($node->children, $preCallback, $postCallback); + $node->children = self::walk($node->children, $preCallback, $postCallback); } - $processed = $postCallback($node); + $node = $postCallback($node) ?? $node; - $result[] = $processed ?? $node; + $result[] = $node; } return $result; } + + /** + * @return \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 self::iterate($node->children); + } + } + } + + public static function filter(array $nodes, callable $predicate): array + { + return iterator_to_array((function () use ($nodes, $predicate) { + 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 55891266..0cae9e07 100644 --- a/src/Runtime/BlazeRuntime.php +++ b/src/Runtime/BlazeRuntime.php @@ -7,11 +7,10 @@ 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\Debugger; +use Livewire\Blaze\Support\ComponentRepository; +use Livewire\Blaze\Support\ComponentSource; /** * Runtime context shared with all Blaze-compiled components via $__blaze. @@ -41,6 +40,7 @@ public function __construct( public Debugger $debugger, protected BladeCompiler $compiler, protected BladeService $blade, + protected ComponentRepository $components, ) { } @@ -85,54 +85,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 { - if (isset($this->paths[$component])) { - $path = $this->paths[$component]; - } else { - $path = $this->paths[$component] = $this->blade->componentNameToPath($component); - } + $component = $this->components->get($name); - if (! $this->isBlazeComponent($path)) { + if (! $component || ! $this->isBlazeComponent($component)) { return false; } - $hash = Utils::hash($path); - $compiled = $this->getCompiledPath().'/'.$hash.'.php'; - - if (! function_exists(($this->folding ? '__' : '_') . basename($compiled, '.php'))) { - $this->compile($path, $compiled); + $compiled = $this->getCompiledPath().'/'.$component->hash.'.php'; - require $compiled; + if (! isset($this->required[$compiled])) { + $this->ensureRequired($component->path, $compiled); } - return $hash; + return $component->hash; } /** * Check if a component file is a Blaze component. */ - protected function isBlazeComponent(string $path): bool + protected function isBlazeComponent(ComponentSource $component): 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 ($component->template->directives->blaze()) { + return $this->blazed[$component->path] = true; } $config = app('blaze.config'); - return $this->blazed[$path] = $config->shouldCompile($path) - || $config->shouldMemoize($path) - || $config->shouldFold($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 new file mode 100644 index 00000000..91ff2721 --- /dev/null +++ b/src/Support/ComponentRepository.php @@ -0,0 +1,39 @@ +components)) { + return $this->components[$name]; + } + + $path = $this->blade->componentNameToPath($name); + + if (! file_exists($path)) { + return $this->components[$name] = null; + } + + $template = $this->parser->parse(file_get_contents($path), $path); + + return $this->components[$name] = new ComponentSource($name, $path, $template); + } + + public function flushState(): void + { + $this->components = []; + } +} diff --git a/src/Support/ComponentSource.php b/src/Support/ComponentSource.php index 9bca2cc4..517db9fd 100644 --- a/src/Support/ComponentSource.php +++ b/src/Support/ComponentSource.php @@ -2,44 +2,17 @@ namespace Livewire\Blaze\Support; -/** - * Resolves and caches a component's file path and directive metadata. - */ +use Livewire\Blaze\Parser\Template; + 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; - /** - * 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 Template $template, + ) { + $this->hash = Utils::hash($path); } -} +} \ No newline at end of file diff --git a/src/Support/Directives.php b/src/Support/Directives.php index 64dfc8af..e3b4114d 100644 --- a/src/Support/Directives.php +++ b/src/Support/Directives.php @@ -2,27 +2,24 @@ 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; + protected array $props; + protected array $aware; + protected array $blaze; - 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::keyBy($nodes, 'name'); } /** @@ -30,9 +27,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 +35,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; } /** @@ -64,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 = []; } /** @@ -78,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 = []; } /** @@ -94,55 +95,14 @@ public function blaze(?string $param = null): mixed return $this->has('blaze'); } - if ($expression = $this->get('blaze')) { - return Utils::parseBlazeDirective($expression)[$param] ?? null; + if (isset($this->blaze) && array_key_exists($param, $this->blaze)) { + return $this->blaze[$param]; } - 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; + if ($expression = $this->get('blaze')) { + return $this->blaze[$param] = Utils::parseBlazeDirective($expression)[$param] ?? null; } - $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; + return $this->blaze[$param] = null; } } diff --git a/src/Support/LaravelRegex.php b/src/Support/LaravelRegex.php index 3d0b76f4..1dde1d59 100644 --- a/src/Support/LaravelRegex.php +++ b/src/Support/LaravelRegex.php @@ -12,26 +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 */ 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. * @@ -54,30 +37,53 @@ 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() — /(?...) + * @see ComponentTagCompiler::compileSelfClosingTags() — (?...) */ - const BLADE_STATEMENT = '/^@(@?\w+(?:::\w+)?)([ \t]*)(\( ( [\S\s]*? ) \))?/x'; + const ATTRIBUTES = "(? + (?: + \s+ + (?: + (?: + @(?:class)(\( (?: (?>[^()]+) | (?-1) )* \)) + ) + | + (?: + @(?:style)(\( (?: (?>[^()]+) | (?-1) )* \)) + ) + | + (?: + \{\{\s*\\\$attributes(?:[^}]+?)?\s*\}\} + ) + | + (?: + (\:\\\$)(\w+) + ) + | + (?: + [\w\-:.@%]+ + ( + = + (?: + \\\"[^\\\"]*\\\" + | + \'[^\']*\' + | + [^\'\\\"=<>]+ + ) + )? + ) + ) + )* + \s* + )"; } diff --git a/src/Unblaze.php b/src/Unblaze.php index 2fffbb35..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; /** @@ -17,53 +16,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 +36,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/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/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 () { diff --git a/tests/Compiler/CompilerTest.php b/tests/Compiler/CompilerTest.php index 8bd3b1bb..5fbe0c38 100644 --- a/tests/Compiler/CompilerTest.php +++ b/tests/Compiler/CompilerTest.php @@ -3,15 +3,18 @@ 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; 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); + expect($compiled)->toBeInstanceOf(CompiledBlockNode::class); + $path = fixture_path('views/components/input.blade.php'); $hash = Utils::hash($path); @@ -37,9 +40,11 @@ BLADE ; - $node = app(Parser::class)->parse($input)[0]; + $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); @@ -63,12 +68,32 @@ ])); }); +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 = ''; - $node = app(Parser::class)->parse($input)[0]; + $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()); ?> ', @@ -88,7 +113,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/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); -}); 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/Compiler/WrapperTest.php b/tests/Compiler/WrapperTest.php index 5d3e940f..a4fae930 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)->nodes; + $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)->nodes; + $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('
')->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) { - expect(app(Wrapper::class)->wrap($source, ''))->toContain( + $ast = app(Parser::class)->parse($source)->nodes; + $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)->nodes; + $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}}')->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) { - // 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)->nodes; + $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)->nodes; + $wrapped = join('', array_map(fn ($node) => $node->render(), app(Wrapper::class)->wrap($ast, ''))); - expect(app(Wrapper::class)->wrap($input, ''))->toContain($input); + expect($wrapped)->toContain('@php /* uncompiled */ @endphp'); }); test('preserves verbatim directives', function () { $input = '@verbatim /* uncompiled */ @endverbatim'; + $ast = app(Parser::class)->parse($input)->nodes; + $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/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 c0a0889b..93648739 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; @@ -11,16 +11,16 @@ 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); + expect($folded)->toBeInstanceOf(CompiledBlockNode::class); }); 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,43 +29,43 @@ 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); + expect($folded)->toBeInstanceOf(CompiledBlockNode::class); }); 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); + expect($folded)->toBeInstanceOf(CompiledBlockNode::class); })->with(['true', 'false']); 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); + expect($folded)->toBeInstanceOf(CompiledBlockNode::class); }); 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); + expect($folded)->toBeInstanceOf(CompiledBlockNode::class); }); 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('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"') @@ -97,16 +97,16 @@ 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); + expect($folded)->toBeInstanceOf(CompiledBlockNode::class); }); 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); @@ -115,7 +115,25 @@ 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); +}); + +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); @@ -124,16 +142,16 @@ 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); + expect($folded)->toBeInstanceOf(CompiledBlockNode::class); }); 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); @@ -142,16 +160,16 @@ 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); + expect($folded)->toBeInstanceOf(CompiledBlockNode::class); }); 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); @@ -160,7 +178,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); @@ -169,7 +187,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); @@ -178,10 +196,10 @@ 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); + expect($folded)->toBeInstanceOf(CompiledBlockNode::class); }); test('folds components with named only slots and whitespace with unsafe slot keyword', function () { @@ -189,16 +207,16 @@ 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); + expect($folded)->toBeInstanceOf(CompiledBlockNode::class); }); 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); @@ -207,16 +225,16 @@ 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); + expect($folded)->toBeInstanceOf(CompiledBlockNode::class); }); 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"') ); @@ -229,33 +247,33 @@ 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"') ); $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 () { $input = ''; - $node = app(Parser::class)->parse($input)[0]; + $node = app(Parser::class)->parse($input)->nodes[0]; $node->setParentsAttributes( app(AttributeParser::class)->parse(':type="true"') ); $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 () { $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); @@ -266,7 +284,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); @@ -277,10 +295,10 @@ 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); + expect($folded)->toBeInstanceOf(CompiledBlockNode::class); }); test('folds components with blaze directive even if disabled in config', function () { @@ -288,16 +306,16 @@ 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); + expect($folded)->toBeInstanceOf(CompiledBlockNode::class); }); 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); @@ -306,7 +324,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); @@ -315,26 +333,26 @@ 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); + expect($result)->toBeInstanceOf(CompiledBlockNode::class); }); 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); + expect($result)->toBeInstanceOf(CompiledBlockNode::class); }); 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); + expect($result)->toBeInstanceOf(CompiledBlockNode::class); }); diff --git a/tests/Folder/UnblazeTest.php b/tests/Folder/UnblazeTest.php index bdc7c907..8c969a0e 100644 --- a/tests/Folder/UnblazeTest.php +++ b/tests/Folder/UnblazeTest.php @@ -1,14 +1,18 @@ Artisan::call('view:clear')); 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( @@ -24,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( @@ -40,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( diff --git a/tests/Memoizer/MemoizerTest.php b/tests/Memoizer/MemoizerTest.php index 98725025..663cea88 100644 --- a/tests/Memoizer/MemoizerTest.php +++ b/tests/Memoizer/MemoizerTest.php @@ -4,13 +4,13 @@ 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 () { $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,10 +60,10 @@ 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); + expect($memoized)->toBeInstanceOf(CompiledBlockNode::class); }); test('does not memoize components with blaze directive override set to false', function () { @@ -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/AttributeTest.php b/tests/Parser/AttributeTest.php index c9d4db47..043ae7f8 100644 --- a/tests/Parser/AttributeTest.php +++ b/tests/Parser/AttributeTest.php @@ -1,7 +1,7 @@ parse(':foo="true"')['foo']; 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 4c214ee3..aae3aed2 100644 --- a/tests/Parser/ParserTest.php +++ b/tests/Parser/ParserTest.php @@ -3,15 +3,18 @@ use Livewire\Blaze\Parser\Parser; 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\Support\AttributeParser; 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-', @@ -22,10 +25,24 @@ ]); }); +test('parses flux components', function () { + $input = ''; + + expect(app(Parser::class)->parse($input)->nodes)->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'; - expect(app(Parser::class)->parse($input))->toEqual([ + expect(app(Parser::class)->parse($input)->nodes)->toEqual([ new ComponentNode( name: 'card', prefix: 'x-', @@ -43,10 +60,23 @@ ]); }); +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'; - expect(app(Parser::class)->parse($input))->toEqual([ + expect(app(Parser::class)->parse($input)->nodes)->toEqual([ new ComponentNode( name: 'card', prefix: 'x-', @@ -68,7 +98,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-', @@ -91,7 +121,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-', @@ -110,7 +140,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([ @@ -121,7 +151,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([ @@ -146,7 +176,37 @@ 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), ]); }); + +test('parses PHP and verbatim blocks', function () { + $input = ' @verbatim @endverbatim @php echo "footer"; @endphp '; + + expect(app(Parser::class)->parse($input)->nodes)->toEqual([ + new ComponentNode( + name: 'card', + prefix: 'x-', + children: [ + new TextNode(' '), + new PhpBlockNode(''), + new TextNode(' '), + new VerbatimBlockNode('@verbatim @endverbatim'), + new TextNode(' '), + new PhpBlockNode('@php echo "footer"; @endphp'), + new TextNode(' '), + ], + ), + ]); +}); + +test('parses echo expressions as nodes', function () { + $input = 'Price: {{ $price }} and $plainText'; + + expect(app(Parser::class)->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 f47b8d30..2fd6cbb7 100644 --- a/tests/Parser/TokenizerTest.php +++ b/tests/Parser/TokenizerTest.php @@ -1,65 +1,63 @@ '; +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 TagOpenToken(prefix: 'x-', name: 'button', attributes: 'type="button"', original: '', selfClosing: false), + new TagCloseToken(prefix: 'x-', name: '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 TagOpenToken(prefix: 'x-', name: '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 TagOpenToken(prefix: 'flux:', name: 'button', attributes: 'type="button"', original: '', selfClosing: false), + new TagCloseToken(prefix: 'flux:', name: '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 TagOpenToken(prefix: 'x-', name: 'button', attributes: '', original: '', selfClosing: false), + new TextToken(''), ]); }); @@ -83,85 +81,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 TagOpenToken(prefix: 'x-', name: 'button', attributes: '', original: '', selfClosing: false), + new PhpBlockToken(content: ' ?>'), + new TagCloseToken(prefix: 'x-', name: '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... - - $result = app(Tokenizer::class)->tokenize($input); +test('handles Blade php blocks', function () { + $input = ' @php $value = ""; @endphp '; - expect($result)->toEqual([ - new SlotOpenToken(name: 'header', slotStyle: 'short', prefix: 'x-slot'), - new SlotCloseToken(name: 'header'), + expect(app(Tokenizer::class)->tokenize($input))->toEqual([ + new TagOpenToken(prefix: 'x-', name: 'button', attributes: '', original: '', selfClosing: false), + new TextToken(' '), + new PhpBlockToken(content: '@php $value = ""; @endphp'), + new TextToken(' '), + new TagCloseToken(prefix: 'x-', name: 'button', original: ''), ]); }); -test('handles attributes with angled brackets', function () { - $input = ''; +test('handles unclosed Blade php blocks', function () { + $input = '@php $value = "";'; - $result = app(Tokenizer::class)->tokenize($input); - - expect($result)->toEqual([ + expect(app(Tokenizer::class)->tokenize($input))->toEqual([ + new DirectiveToken(name: 'php', original: '@php'), + new TextToken(content: ' $value = "'), new TagOpenToken( - name: 'button', - prefix: 'x-', - attributes: [ - ':data="[\'foo\' => \'bar\']"', - ':callback="fn () => 0"', - ], + prefix: 'x-', name: 'button', + attributes: '', + 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: '>'), ]); }); @@ -294,3 +272,38 @@ new TextToken(content: '))'), ]); }); + +test('handles comments', function () { + $input = '{{-- Comment --}}'; + + $result = app(Tokenizer::class)->tokenize($input); + + expect($result)->toEqual([ + new TagOpenToken(prefix: 'x-', name: 'button', attributes: '', original: '', selfClosing: true), + ]); +}); + +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), + ]); +}); 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..17203c87 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\'])')->nodes + ); 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\'])')->nodes + ); expect($directives->props())->toBe(['name', 'value']); }); test('parses blaze directive', function () { - $directives = new Directives('@blaze'); + $directives = new Directives( + app(Parser::class)->parse('@blaze')->nodes + ); 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\'])')->nodes + ); 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 --}}'], -]);