├── tests ├── fixtures │ ├── components │ │ ├── kanban │ │ │ └── comments │ │ │ │ └── comments.blade.php │ │ ├── memoize.blade.php │ │ ├── form │ │ │ ├── input.blade.php │ │ │ ├── fields │ │ │ │ └── text.blade.php │ │ │ └── index.blade.php │ │ ├── card.blade.php │ │ ├── invalid-foldable │ │ │ ├── auth.blade.php │ │ │ ├── old.blade.php │ │ │ ├── request.blade.php │ │ │ ├── csrf.blade.php │ │ │ ├── session.blade.php │ │ │ ├── errors.blade.php │ │ │ ├── aware.blade.php │ │ │ ├── error.blade.php │ │ │ └── once.blade.php │ │ ├── unfoldable-button.blade.php │ │ ├── alert.blade.php │ │ ├── date.blade.php │ │ ├── button-no-fold.blade.php │ │ ├── button.blade.php │ │ ├── item.blade.php │ │ ├── foldable-item.blade.php │ │ ├── random-static.blade.php │ │ ├── with-request-inside-unblaze.blade.php │ │ ├── with-unblaze.blade.php │ │ ├── with-unblaze-scope.blade.php │ │ ├── with-csrf-inside-unblaze.blade.php │ │ ├── with-errors-outside-unblaze.blade.php │ │ ├── group.blade.php │ │ ├── modal.blade.php │ │ ├── panel │ │ │ └── panel.blade.php │ │ ├── with-errors-inside-unblaze.blade.php │ │ ├── mixed-random.blade.php │ │ ├── scoped-dynamic.blade.php │ │ └── avatar.blade.php │ ├── pages │ │ ├── auth │ │ │ ├── index.blade.php │ │ │ └── login.blade.php │ │ ├── dashboard.blade.php │ │ ├── invalid-foldable-test.blade.php │ │ └── integration-test.blade.php │ ├── layouts │ │ └── app.blade.php │ └── benchmark │ │ ├── simple-button-in-loop.blade.php │ │ └── no-fold-button-in-loop.blade.php ├── Pest.php ├── TestCase.php ├── FluxTest.php ├── MemoizeTest.php ├── BenchmarkTest.php ├── LookupTest.php ├── BlazeTest.php ├── CacheInvalidationTest.php ├── InfiniteRecursionTest.php ├── BladeServiceTest.php ├── ExcerciseTest.php ├── FoldTest.php ├── UnblazeTest.php └── AttributeParserTest.php ├── .gitignore ├── src ├── Tokenizer │ ├── Tokens │ │ ├── Token.php │ │ ├── TextToken.php │ │ ├── SlotCloseToken.php │ │ ├── TagCloseToken.php │ │ ├── SlotOpenToken.php │ │ ├── TagOpenToken.php │ │ └── TagSelfCloseToken.php │ ├── State.php │ └── Tokenizer.php ├── Events │ └── ComponentFolded.php ├── Nodes │ ├── Node.php │ ├── TextNode.php │ ├── SlotNode.php │ └── ComponentNode.php ├── Walker │ └── Walker.php ├── Exceptions │ ├── LeftoverPlaceholdersException.php │ └── InvalidBlazeFoldUsageException.php ├── Blaze.php ├── Memoizer │ ├── Memo.php │ └── Memoizer.php ├── FrontMatter.php ├── Parser │ ├── ParseStack.php │ └── Parser.php ├── Directive │ └── BlazeDirective.php ├── BlazeServiceProvider.php ├── Unblaze.php ├── BlazeManager.php ├── BladeService.php ├── Folder │ └── Folder.php └── Support │ └── AttributeParser.php ├── .github ├── ISSUE_TEMPLATE │ ├── config.yml │ ├── feature_request.md │ └── bug_report.md └── workflows │ └── tests.yml ├── phpunit.xml ├── LICENSE ├── composer.json ├── AGENTS.md └── README.md /tests/fixtures/components/kanban/comments/comments.blade.php: -------------------------------------------------------------------------------- 1 | Comments -------------------------------------------------------------------------------- /tests/Pest.php: -------------------------------------------------------------------------------- 1 | in(__DIR__); -------------------------------------------------------------------------------- /tests/fixtures/pages/auth/index.blade.php: -------------------------------------------------------------------------------- 1 | @props([]) 2 | 3 |
{{ $slot }}
4 | -------------------------------------------------------------------------------- /tests/fixtures/pages/auth/login.blade.php: -------------------------------------------------------------------------------- 1 | @props([]) 2 | 3 |
{{ $slot }}
4 | -------------------------------------------------------------------------------- /tests/fixtures/pages/dashboard.blade.php: -------------------------------------------------------------------------------- 1 | @props([]) 2 | 3 |
{{ $slot }}
4 | -------------------------------------------------------------------------------- /tests/fixtures/components/memoize.blade.php: -------------------------------------------------------------------------------- 1 | @blaze(fold: false) 2 | 3 |
{{ str()->random() }}
4 | -------------------------------------------------------------------------------- /tests/fixtures/layouts/app.blade.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | {{ $slot }} 4 | 5 | -------------------------------------------------------------------------------- /tests/fixtures/components/form/input.blade.php: -------------------------------------------------------------------------------- 1 | @blaze 2 | 3 | @props([]) 4 | 5 | 6 | -------------------------------------------------------------------------------- /tests/fixtures/components/card.blade.php: -------------------------------------------------------------------------------- 1 | @blaze 2 | 3 | @props([]) 4 | 5 |
6 | {{ $slot }} 7 |
-------------------------------------------------------------------------------- /tests/fixtures/components/form/fields/text.blade.php: -------------------------------------------------------------------------------- 1 | @blaze 2 | 3 | @props([]) 4 | 5 | 6 | -------------------------------------------------------------------------------- /tests/fixtures/benchmark/simple-button-in-loop.blade.php: -------------------------------------------------------------------------------- 1 | 2 | @for ($i = 0; $i < 25000; $i++) 3 | Hi 4 | @endfor -------------------------------------------------------------------------------- /tests/fixtures/components/invalid-foldable/auth.blade.php: -------------------------------------------------------------------------------- 1 | @blaze 2 | 3 |
4 | Welcome, {{ auth()->user()->name }} 5 |
6 | -------------------------------------------------------------------------------- /tests/fixtures/components/invalid-foldable/old.blade.php: -------------------------------------------------------------------------------- 1 | @blaze 2 | 3 | 4 | -------------------------------------------------------------------------------- /tests/fixtures/components/unfoldable-button.blade.php: -------------------------------------------------------------------------------- 1 | @props(['type' => 'button']) 2 | 3 | -------------------------------------------------------------------------------- /tests/fixtures/components/alert.blade.php: -------------------------------------------------------------------------------- 1 | @blaze 2 | 3 | @props(['message' => null]) 4 | 5 |
{{ $message ?? $slot }}
-------------------------------------------------------------------------------- /tests/fixtures/components/invalid-foldable/request.blade.php: -------------------------------------------------------------------------------- 1 | @blaze 2 | 3 |
4 | Current URL: {{ request()->url() }} 5 |
6 | -------------------------------------------------------------------------------- /tests/fixtures/components/date.blade.php: -------------------------------------------------------------------------------- 1 | @blaze 2 | 3 | @props(['date']) 4 | 5 |
Date is: {{ (new DateTime($date))->format('D, M d') }}
-------------------------------------------------------------------------------- /tests/fixtures/components/invalid-foldable/csrf.blade.php: -------------------------------------------------------------------------------- 1 | @blaze 2 | 3 |
4 | @csrf 5 | {{ $slot }} 6 |
7 | -------------------------------------------------------------------------------- /tests/fixtures/components/invalid-foldable/session.blade.php: -------------------------------------------------------------------------------- 1 | @blaze 2 | 3 |
4 | {{ session('message') }} 5 |
6 | -------------------------------------------------------------------------------- /tests/fixtures/benchmark/no-fold-button-in-loop.blade.php: -------------------------------------------------------------------------------- 1 | 2 | @for ($i = 0; $i < 25000; $i++) 3 | Hi 4 | @endfor -------------------------------------------------------------------------------- /tests/fixtures/components/button-no-fold.blade.php: -------------------------------------------------------------------------------- 1 | @props(['type' => 'button']) 2 | 3 | -------------------------------------------------------------------------------- /tests/fixtures/components/button.blade.php: -------------------------------------------------------------------------------- 1 | @blaze 2 | 3 | @props(['type' => 'button']) 4 | 5 | -------------------------------------------------------------------------------- /tests/fixtures/components/invalid-foldable/errors.blade.php: -------------------------------------------------------------------------------- 1 | @blaze 2 | 3 |
4 | {{ $slot }} 5 |
6 | -------------------------------------------------------------------------------- /tests/fixtures/components/invalid-foldable/aware.blade.php: -------------------------------------------------------------------------------- 1 | @blaze 2 | 3 | @aware(['variant' => 'default']) 4 | 5 |
6 | {{ $slot }} 7 |
8 | -------------------------------------------------------------------------------- /tests/fixtures/components/item.blade.php: -------------------------------------------------------------------------------- 1 | @aware(['variant', 'secondVariant' => null ]) 2 | 3 |
-------------------------------------------------------------------------------- /tests/fixtures/components/foldable-item.blade.php: -------------------------------------------------------------------------------- 1 | @blaze 2 | 3 | @aware(['variant', 'secondVariant' => null ]) 4 | 5 |
-------------------------------------------------------------------------------- /tests/fixtures/components/invalid-foldable/error.blade.php: -------------------------------------------------------------------------------- 1 | @blaze 2 | 3 | @props(['name']) 4 | 5 |
6 | @error($name) 7 | {{ $message }} 8 | @enderror 9 |
10 | -------------------------------------------------------------------------------- /tests/fixtures/components/random-static.blade.php: -------------------------------------------------------------------------------- 1 | @blaze 2 |
3 |

Random: {{ \Illuminate\Support\Str::random(20) }}

4 |

This should be folded and not change between renders

5 |
6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor/ 2 | /node_modules/ 3 | /.phpunit.cache/ 4 | /.idea/ 5 | /.vscode/ 6 | /.claude/ 7 | .DS_Store 8 | composer.lock 9 | phpunit.xml.dist 10 | .env 11 | .env.testing 12 | tests/fixtures/compiled/ 13 | tests/fixtures/blaze/ -------------------------------------------------------------------------------- /src/Tokenizer/Tokens/Token.php: -------------------------------------------------------------------------------- 1 | 3 | Home 4 | @unblaze 5 | About 6 | @endunblaze 7 | 8 | -------------------------------------------------------------------------------- /tests/fixtures/components/with-unblaze.blade.php: -------------------------------------------------------------------------------- 1 | @blaze 2 |
3 |

Static Header

4 | @unblaze 5 |

Dynamic content: {{ $dynamicValue }}

6 | @endunblaze 7 | 8 |
9 | -------------------------------------------------------------------------------- /src/Events/ComponentFolded.php: -------------------------------------------------------------------------------- 1 | 6 |

{{ $title }}

7 | @once 8 | 9 | @endonce 10 | 11 | -------------------------------------------------------------------------------- /tests/fixtures/components/with-unblaze-scope.blade.php: -------------------------------------------------------------------------------- 1 | @blaze 2 |
3 |

Title

4 | @unblaze(scope: ['message' => $message]) 5 |
{{ $scope['message'] }}
6 | @endunblaze 7 |

Static paragraph

8 |
9 | -------------------------------------------------------------------------------- /tests/fixtures/components/with-csrf-inside-unblaze.blade.php: -------------------------------------------------------------------------------- 1 | @blaze 2 |
3 |

Form

4 | @unblaze 5 |
6 | @csrf 7 | 8 |
9 | @endunblaze 10 |
11 | -------------------------------------------------------------------------------- /tests/fixtures/pages/invalid-foldable-test.blade.php: -------------------------------------------------------------------------------- 1 |
2 |

Invalid Foldable Test

3 | 4 | 5 | This should fail 6 |
7 | -------------------------------------------------------------------------------- /tests/fixtures/components/with-errors-outside-unblaze.blade.php: -------------------------------------------------------------------------------- 1 | @blaze 2 |
3 | 4 | 5 | @if($errors->has('email')) 6 | {{ $errors->first('email') }} 7 | @endif 8 |
9 | -------------------------------------------------------------------------------- /tests/fixtures/components/group.blade.php: -------------------------------------------------------------------------------- 1 | @blaze 2 | 3 | @props(['variant' => '', 'dataTest' => 'foo', 'secondVariant' => null]) 4 | 5 |
merge(['class' => 'group group-'.$variant, 'data-test' => $dataTest]) }}@if($secondVariant) data-second-variant="{{ $secondVariant }}"@endif>{{ $slot }}
-------------------------------------------------------------------------------- /tests/fixtures/components/modal.blade.php: -------------------------------------------------------------------------------- 1 | @blaze 2 | 3 | @props([ 4 | 'header' => '', 5 | 'footer' => '', 6 | ]) 7 | 8 | -------------------------------------------------------------------------------- /tests/fixtures/components/form/index.blade.php: -------------------------------------------------------------------------------- 1 | @blaze 2 | 3 | @props([]) 4 | 5 |
6 |
7 | {{ $header }} 8 |
9 |
10 | {{ $slot }} 11 |
12 | 15 |
16 | -------------------------------------------------------------------------------- /tests/fixtures/components/panel/panel.blade.php: -------------------------------------------------------------------------------- 1 | @blaze 2 | 3 | @props([]) 4 | 5 |
6 |
7 | {{ $header }} 8 |
9 |
10 | {{ $slot }} 11 |
12 | 15 |
16 | -------------------------------------------------------------------------------- /tests/fixtures/components/with-errors-inside-unblaze.blade.php: -------------------------------------------------------------------------------- 1 | @blaze 2 |
3 | 4 | 5 | @unblaze 6 | @if($errors->has('email')) 7 | {{ $errors->first('email') }} 8 | @endif 9 | @endunblaze 10 |
11 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: true 2 | contact_links: 3 | - name: Ask a Question 4 | url: https://github.com/livewire/blaze/discussions 5 | about: Ask questions and discuss with other community members 6 | - name: Documentation 7 | url: https://github.com/livewire/blaze#readme 8 | about: Check the README for usage instructions -------------------------------------------------------------------------------- /tests/fixtures/components/mixed-random.blade.php: -------------------------------------------------------------------------------- 1 | @blaze 2 |
3 |

Static Random: {{ \Illuminate\Support\Str::random(20) }}

4 | @unblaze 5 |

Dynamic value: {{ $dynamicValue ?? 'none' }}

6 | @endunblaze 7 | 8 |
9 | -------------------------------------------------------------------------------- /tests/fixtures/components/scoped-dynamic.blade.php: -------------------------------------------------------------------------------- 1 | @blaze 2 |
3 |

Static: {{ \Illuminate\Support\Str::random(15) }}

4 | @unblaze(scope: ['value' => $value]) 5 |
Value: {{ $scope['value'] }}
6 | @endunblaze 7 |

Static paragraph: {{ \Illuminate\Support\Str::random(10) }}

8 |
9 | -------------------------------------------------------------------------------- /tests/fixtures/pages/integration-test.blade.php: -------------------------------------------------------------------------------- 1 |
2 |

Integration Test Page

3 | 4 | 5 | Save Changes 6 | 7 | 8 | Cancel 9 | 10 | 11 | 12 | 13 | 14 |
-------------------------------------------------------------------------------- /src/Tokenizer/State.php: -------------------------------------------------------------------------------- 1 | $this->getType(), 20 | 'content' => $this->content, 21 | ]; 22 | } 23 | } -------------------------------------------------------------------------------- /src/Tokenizer/Tokens/SlotCloseToken.php: -------------------------------------------------------------------------------- 1 | $this->getType(), 21 | 'name' => $this->name, 22 | ]; 23 | } 24 | } -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for Blaze 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem?** 11 | A clear description of what the problem is. Ex. I'm frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | What you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | Any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any code examples or use cases here. -------------------------------------------------------------------------------- /src/Nodes/TextNode.php: -------------------------------------------------------------------------------- 1 | $this->getType(), 20 | 'content' => $this->content, 21 | ]; 22 | } 23 | 24 | public function render(): string 25 | { 26 | return $this->content; 27 | } 28 | } -------------------------------------------------------------------------------- /src/Tokenizer/Tokens/TagCloseToken.php: -------------------------------------------------------------------------------- 1 | $this->getType(), 22 | 'name' => $this->name, 23 | 'prefix' => $this->prefix, 24 | 'namespace' => $this->namespace, 25 | ]; 26 | } 27 | } -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Report an issue with Blaze 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **Component causing the issue** 14 | ```blade 15 | 16 | ``` 17 | 18 | **Expected behavior** 19 | What you expected to happen when using `@blaze`. 20 | 21 | **Actual behavior** 22 | What actually happened instead. 23 | 24 | **Environment** 25 | - Laravel version: [e.g. 11.0] 26 | - PHP version: [e.g. 8.2] 27 | - Blaze version: [e.g. 1.0.0] 28 | 29 | **Additional context** 30 | Add any other context about the problem here (error messages, stack traces, etc.) 31 | -------------------------------------------------------------------------------- /src/Tokenizer/Tokens/SlotOpenToken.php: -------------------------------------------------------------------------------- 1 | $this->getType(), 23 | 'name' => $this->name, 24 | 'attributes' => $this->attributes, 25 | 'slot_style' => $this->slotStyle, 26 | ]; 27 | } 28 | } -------------------------------------------------------------------------------- /src/Tokenizer/Tokens/TagOpenToken.php: -------------------------------------------------------------------------------- 1 | $this->getType(), 23 | 'name' => $this->name, 24 | 'prefix' => $this->prefix, 25 | 'namespace' => $this->namespace, 26 | 'attributes' => $this->attributes, 27 | ]; 28 | } 29 | } -------------------------------------------------------------------------------- /tests/TestCase.php: -------------------------------------------------------------------------------- 1 | set('view.paths', [ 26 | __DIR__ . '/fixtures/views', 27 | ]); 28 | 29 | $app['config']->set('view.compiled', sys_get_temp_dir() . '/views'); 30 | } 31 | } -------------------------------------------------------------------------------- /src/Tokenizer/Tokens/TagSelfCloseToken.php: -------------------------------------------------------------------------------- 1 | $this->getType(), 23 | 'name' => $this->name, 24 | 'prefix' => $this->prefix, 25 | 'namespace' => $this->namespace, 26 | 'attributes' => $this->attributes, 27 | ]; 28 | } 29 | } -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | tests 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | src 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /src/Walker/Walker.php: -------------------------------------------------------------------------------- 1 | children)) { 18 | $node->children = $this->walk($node->children, $preCallback, $postCallback); 19 | } 20 | 21 | $processed = $postCallback($node); 22 | 23 | $result[] = $processed ?? $node; 24 | } 25 | 26 | return $result; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/Exceptions/LeftoverPlaceholdersException.php: -------------------------------------------------------------------------------- 1 | componentName; 20 | } 21 | 22 | public function getLeftoverSummary(): string 23 | { 24 | return $this->leftoverSummary; 25 | } 26 | 27 | public function getRenderedSnippet(): ?string 28 | { 29 | return $this->renderedSnippet; 30 | } 31 | } -------------------------------------------------------------------------------- /src/Blaze.php: -------------------------------------------------------------------------------- 1 | register(\Livewire\LivewireServiceProvider::class); 9 | app()->register(\Flux\FluxServiceProvider::class); 10 | }); 11 | 12 | it('folds flux heading component with static props', function () { 13 | $input = 'Hello World'; 14 | $output = app('blaze')->compile($input); 15 | 16 | // Should be folded to a div (default level) 17 | expect($output)->toContain('toContain('Hello World'); 19 | expect($output)->toContain('data-flux-heading'); 20 | expect($output)->not->toContain('flux:heading'); 21 | }); 22 | 23 | it('folds link component with dynamic route helper link', function() { 24 | Route::get('/dashboard', fn() => 'dashboard')->name('dashboard'); 25 | 26 | $input = 'Dashboard'; 27 | $output = app('blaze')->compile($input); 28 | 29 | expect($output) 30 | ->toContain('toContain(' href="{{ route(\'dashboard\') }}"'); 32 | }); 33 | }); 34 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "livewire/blaze", 3 | "description": "A tool for optimizing Blade component performance by folding them into parent templates", 4 | "type": "library", 5 | "license": "MIT", 6 | "authors": [ 7 | { 8 | "name": "Caleb Porzio" 9 | } 10 | ], 11 | "require": { 12 | "php": "^8.1", 13 | "illuminate/support": "^10.0|^11.0|^12.0", 14 | "illuminate/view": "^10.0|^11.0|^12.0" 15 | }, 16 | "require-dev": { 17 | "livewire/flux": "dev-main", 18 | "orchestra/testbench": "^8.0|^9.0|^10.0", 19 | "pestphp/pest": "^2.0|^3.0", 20 | "pestphp/pest-plugin-laravel": "^2.0|^3.0" 21 | }, 22 | "autoload": { 23 | "psr-4": { 24 | "Livewire\\Blaze\\": "src/" 25 | } 26 | }, 27 | "autoload-dev": { 28 | "psr-4": { 29 | "Livewire\\Blaze\\Tests\\": "tests/" 30 | } 31 | }, 32 | "extra": { 33 | "laravel": { 34 | "providers": [ 35 | "Livewire\\Blaze\\BlazeServiceProvider" 36 | ] 37 | } 38 | }, 39 | "config": { 40 | "sort-packages": true, 41 | "allow-plugins": { 42 | "pestphp/pest-plugin": true 43 | } 44 | }, 45 | "minimum-stability": "dev", 46 | "prefer-stable": true, 47 | "scripts": { 48 | "test": "vendor/bin/pest" 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | branches: [ "**" ] 6 | pull_request: 7 | branches: [ "**" ] 8 | 9 | jobs: 10 | pest: 11 | runs-on: ubuntu-latest 12 | strategy: 13 | fail-fast: false 14 | matrix: 15 | php: [ '8.2', '8.3', '8.4' ] 16 | 17 | steps: 18 | - name: Checkout 19 | uses: actions/checkout@v4 20 | 21 | - name: Setup PHP 22 | uses: shivammathur/setup-php@v2 23 | with: 24 | php-version: ${{ matrix.php }} 25 | tools: composer:v2 26 | coverage: none 27 | extensions: mbstring, dom, curl, json, libxml, xml, xmlwriter, simplexml, tokenizer, pdo, sqlite3 28 | ini-values: error_reporting=E_ALL, display_errors=On, log_errors=Off 29 | 30 | - name: Determine composer cache directory 31 | id: composer-cache 32 | run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT 33 | 34 | - name: Cache composer 35 | uses: actions/cache@v3 36 | with: 37 | path: ${{ steps.composer-cache.outputs.dir }} 38 | key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} 39 | restore-keys: | 40 | ${{ runner.os }}-composer- 41 | 42 | - name: Validate composer.json 43 | run: composer validate --no-check-publish 44 | 45 | - name: Install dependencies 46 | run: composer install --prefer-dist --no-progress --no-interaction 47 | 48 | - name: Run Pest 49 | run: vendor/bin/pest --no-coverage -------------------------------------------------------------------------------- /src/FrontMatter.php: -------------------------------------------------------------------------------- 1 | name ."}:{". $event->path ."}:{".$event->filemtime."} ?>\n"; 19 | } 20 | 21 | return $frontmatter; 22 | } 23 | 24 | public function sourceContainsExpiredFoldedDependencies(string $source): bool 25 | { 26 | $foldedComponents = $this->parseFromTemplate($source); 27 | 28 | if (empty($foldedComponents)) { 29 | return false; 30 | } 31 | 32 | foreach ($foldedComponents as $match) { 33 | $componentPath = $match[2]; 34 | 35 | $storedFilemtime = (int) $match[3]; 36 | 37 | if (! file_exists($componentPath)) { 38 | return true; 39 | } 40 | 41 | $currentFilemtime = filemtime($componentPath); 42 | 43 | if ($currentFilemtime > $storedFilemtime) { 44 | return true; 45 | } 46 | } 47 | 48 | return false; 49 | } 50 | 51 | public function parseFromTemplate(string $template): array 52 | { 53 | preg_match_all('/<'.'?php # \[BlazeFolded\]:\{([^}]+)\}:\{([^}]+)\}:\{([^}]+)\} \?>/', $template, $matches, PREG_SET_ORDER); 54 | 55 | return $matches; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/Parser/ParseStack.php: -------------------------------------------------------------------------------- 1 | stack)) { 18 | $this->ast[] = $node; 19 | } else { 20 | $current = $this->getCurrentContainer(); 21 | 22 | if ($current instanceof ComponentNode || $current instanceof SlotNode) { 23 | $current->children[] = $node; 24 | } else { 25 | // Fallback: if current container cannot accept children, append to root... 26 | $this->ast[] = $node; 27 | } 28 | } 29 | } 30 | 31 | public function pushContainer(Node $container): void 32 | { 33 | $this->addToRoot($container); 34 | 35 | $this->stack[] = $container; 36 | } 37 | 38 | public function popContainer(): ?Node 39 | { 40 | if (! empty($this->stack)) { 41 | return array_pop($this->stack); 42 | } 43 | 44 | return null; 45 | } 46 | 47 | public function getCurrentContainer(): ?Node 48 | { 49 | return empty($this->stack) ? null : end($this->stack); 50 | } 51 | 52 | public function getAst(): array 53 | { 54 | return $this->ast; 55 | } 56 | 57 | public function isEmpty(): bool 58 | { 59 | return empty($this->stack); 60 | } 61 | 62 | public function depth(): int 63 | { 64 | return count($this->stack); 65 | } 66 | } -------------------------------------------------------------------------------- /src/Directive/BlazeDirective.php: -------------------------------------------------------------------------------- 1 | ''); 12 | } 13 | 14 | public static function getParameters(string $source): ?array 15 | { 16 | // If there is no @blaze directive, return null 17 | if (! preg_match('/^\s*(?:\/\*.*?\*\/\s*)*@blaze(?:\s*\(([^)]+)\))?/s', $source, $matches)) { 18 | return null; 19 | } 20 | 21 | // If there are no parameters, return an empty array 22 | if (empty($matches[1])) { 23 | return []; 24 | } 25 | 26 | return self::parseParameters($matches[1]); 27 | } 28 | 29 | /** 30 | * Parse directive parameters 31 | * 32 | * For example, the string: 33 | * "fold: false" 34 | * 35 | * will be parsed into the array: 36 | * [ 37 | * 'fold' => false, 38 | * ] 39 | */ 40 | protected static function parseParameters(string $paramString): array 41 | { 42 | $params = []; 43 | 44 | // Simple parameter parsing for key:value pairs 45 | if (preg_match_all('/(\w+)\s*:\s*(\w+)/', $paramString, $matches, PREG_SET_ORDER)) { 46 | foreach ($matches as $match) { 47 | $key = $match[1]; 48 | $value = $match[2]; 49 | 50 | // Convert string boolean values 51 | if (in_array(strtolower($value), ['true', 'false'])) { 52 | $params[$key] = strtolower($value) === 'true'; 53 | } else { 54 | $params[$key] = $value; 55 | } 56 | } 57 | } 58 | 59 | return $params; 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /tests/MemoizeTest.php: -------------------------------------------------------------------------------- 1 | anonymousComponentNamespace('', 'x'); 9 | app('blade.compiler')->anonymousComponentPath(__DIR__ . '/fixtures/components'); 10 | 11 | // call artisan view:clear: 12 | Artisan::call('view:clear'); 13 | }); 14 | 15 | it('can memoize an unfoldable component', function () { 16 | $template = ''; 17 | 18 | $renderedA = app('blade.compiler')->render($template); 19 | $renderedB = app('blade.compiler')->render($template); 20 | 21 | expect($renderedA)->toContain('
'); 22 | expect($renderedA)->toBe($renderedB); 23 | }); 24 | 25 | it('can memoize based on static attributes', function () { 26 | $renderedA = app('blade.compiler')->render(''); 27 | $renderedB = app('blade.compiler')->render(''); 28 | 29 | expect($renderedA)->toContain('
'); 30 | expect($renderedA)->toBe($renderedB); 31 | 32 | $renderedA = app('blade.compiler')->render(''); 33 | $renderedB = app('blade.compiler')->render(''); 34 | 35 | expect($renderedA)->toContain('
'); 36 | expect($renderedA)->not->toBe($renderedB); 37 | }); 38 | 39 | it('memoization only works on self-closing components', function () { 40 | $renderedA = app('blade.compiler')->render(''); 41 | $renderedB = app('blade.compiler')->render(''); 42 | 43 | expect($renderedA)->toContain('
'); 44 | expect($renderedA)->not->toBe($renderedB); 45 | }); 46 | }); 47 | -------------------------------------------------------------------------------- /tests/BenchmarkTest.php: -------------------------------------------------------------------------------- 1 | anonymousComponentPath(__DIR__ . '/fixtures/components'); 8 | }); 9 | 10 | function render($viewFile, $enabled = false) { 11 | clearCache(); 12 | 13 | // Warm up the compiler/cache... 14 | View::file($viewFile)->render(); 15 | 16 | // Run the benchmark... 17 | $iterations = 1; // single run of a 25k loop inside the view 18 | 19 | $start = microtime(true); 20 | 21 | for ($i = 0; $i < $iterations; $i++) { 22 | View::file($viewFile)->render(); 23 | } 24 | 25 | $duration = (microtime(true) - $start) * 1000; // ms 26 | 27 | return $duration; 28 | } 29 | 30 | function clearCache() { 31 | $files = glob(__DIR__ . '/../vendor/orchestra/testbench-core/laravel/storage/framework/views/*'); 32 | foreach ($files as $file) { 33 | if (!str_ends_with($file, '.gitignore')) { 34 | if (is_dir($file)) { 35 | rmdir($file); 36 | } else { 37 | unlink($file); 38 | } 39 | } 40 | } 41 | } 42 | 43 | it('can run performance benchmarks', function () { 44 | app('blaze')->enable(); 45 | 46 | $viewFile = __DIR__ . '/fixtures/benchmark/simple-button-in-loop.blade.php'; 47 | $duration = render($viewFile, enabled: false); 48 | fwrite(STDOUT, "Blaze enabled - render 25k component loop: " . number_format($duration, 2) . " ms\n"); 49 | 50 | app('blaze')->disable(); 51 | 52 | $viewFile = __DIR__ . '/fixtures/benchmark/simple-button-in-loop.blade.php'; 53 | $duration = render($viewFile, enabled: false); 54 | fwrite(STDOUT, "Blaze disabled - render 25k component loop: " . number_format($duration, 2) . " ms\n"); 55 | 56 | app('blaze')->enable(); 57 | 58 | $viewFile = __DIR__ . '/fixtures/benchmark/no-fold-button-in-loop.blade.php'; 59 | $duration = render($viewFile, enabled: false); 60 | fwrite(STDOUT, "Blaze enabled but no folding - render 25k component loop: " . number_format($duration, 2) . " ms\n"); 61 | 62 | expect(true)->toBeTrue(); 63 | }); 64 | -------------------------------------------------------------------------------- /tests/LookupTest.php: -------------------------------------------------------------------------------- 1 | anonymousComponentPath(__DIR__ . '/fixtures/components'); 8 | app('blade.compiler')->anonymousComponentPath(__DIR__ . '/fixtures/pages', 'pages'); 9 | }); 10 | 11 | it('anonymous component', function () { 12 | $path = (new BladeService)->componentNameToPath('button'); 13 | 14 | expect($path)->toBe(__DIR__ . '/fixtures/components/button.blade.php'); 15 | }); 16 | 17 | it('namespaced anonymous component', function () { 18 | $path = (new BladeService)->componentNameToPath('pages::dashboard'); 19 | 20 | expect($path)->toBe(__DIR__ . '/fixtures/pages/dashboard.blade.php'); 21 | }); 22 | 23 | it('sub-component', function () { 24 | $path = (new BladeService)->componentNameToPath('form.input'); 25 | 26 | expect($path)->toBe(__DIR__ . '/fixtures/components/form/input.blade.php'); 27 | }); 28 | 29 | it('nested sub-component', function () { 30 | $path = (new BladeService)->componentNameToPath('form.fields.text'); 31 | 32 | expect($path)->toBe(__DIR__ . '/fixtures/components/form/fields/text.blade.php'); 33 | }); 34 | 35 | it('root component with index file', function () { 36 | $path = (new BladeService)->componentNameToPath('form'); 37 | 38 | expect($path)->toBe(__DIR__ . '/fixtures/components/form/index.blade.php'); 39 | }); 40 | 41 | it('root component with same-name file', function () { 42 | $path = (new BladeService)->componentNameToPath('panel'); 43 | 44 | expect($path)->toBe(__DIR__ . '/fixtures/components/panel/panel.blade.php'); 45 | }); 46 | 47 | it('namespaced sub-component', function () { 48 | $path = (new BladeService)->componentNameToPath('pages::auth.login'); 49 | 50 | expect($path)->toBe(__DIR__ . '/fixtures/pages/auth/login.blade.php'); 51 | }); 52 | 53 | it('namespaced root component', function () { 54 | $path = (new BladeService)->componentNameToPath('pages::auth'); 55 | 56 | expect($path)->toBe(__DIR__ . '/fixtures/pages/auth/index.blade.php'); 57 | }); 58 | }); 59 | -------------------------------------------------------------------------------- /src/Nodes/SlotNode.php: -------------------------------------------------------------------------------- 1 | $this->getType(), 25 | 'name' => $this->name, 26 | 'attributes' => $this->attributes, 27 | 'slot_style' => $this->slotStyle, 28 | 'prefix' => $this->prefix, 29 | 'close_has_name' => $this->closeHasName, 30 | 'children' => array_map(fn($child) => $child instanceof Node ? $child->toArray() : $child, $this->children), 31 | ]; 32 | } 33 | 34 | public function render(): string 35 | { 36 | if ($this->slotStyle === 'short') { 37 | $output = "<{$this->prefix}:{$this->name}"; 38 | 39 | if (! empty($this->attributes)) { 40 | $output .= " {$this->attributes}"; 41 | } 42 | 43 | $output .= '>'; 44 | 45 | foreach ($this->children as $child) { 46 | $output .= $child instanceof Node ? $child->render() : (string) $child; 47 | } 48 | 49 | // Short syntax may close with or ... 50 | $output .= $this->closeHasName 51 | ? "prefix}:{$this->name}>" 52 | : "prefix}>"; 53 | 54 | return $output; 55 | } 56 | 57 | $output = "<{$this->prefix}"; 58 | 59 | if (! empty($this->name)) { 60 | $output .= ' name="' . $this->name . '"'; 61 | } 62 | 63 | if (! empty($this->attributes)) { 64 | $output .= " {$this->attributes}"; 65 | } 66 | 67 | $output .= '>'; 68 | 69 | foreach ($this->children as $child) { 70 | $output .= $child instanceof Node ? $child->render() : (string) $child; 71 | } 72 | 73 | $output .= "prefix}>"; 74 | 75 | return $output; 76 | } 77 | } -------------------------------------------------------------------------------- /src/Memoizer/Memoizer.php: -------------------------------------------------------------------------------- 1 | componentNameToPath = $componentNameToPath; 17 | } 18 | 19 | public function isMemoizable(Node $node): bool 20 | { 21 | if (! $node instanceof ComponentNode) { 22 | return false; 23 | } 24 | 25 | try { 26 | $componentPath = ($this->componentNameToPath)($node->name); 27 | 28 | if (empty($componentPath) || ! file_exists($componentPath)) { 29 | return false; 30 | } 31 | 32 | $source = file_get_contents($componentPath); 33 | 34 | $directiveParameters = BlazeDirective::getParameters($source); 35 | 36 | if (is_null($directiveParameters)) { 37 | return false; 38 | } 39 | 40 | // Default to true if memo parameter is not specified 41 | return $directiveParameters['memo'] ?? true; 42 | 43 | } catch (\Exception $e) { 44 | return false; 45 | } 46 | } 47 | 48 | public function memoize(Node $node): Node 49 | { 50 | if (! $node instanceof ComponentNode) { 51 | return $node; 52 | } 53 | 54 | if (! $node->selfClosing) { 55 | return $node; 56 | } 57 | 58 | if (! $this->isMemoizable($node)) { 59 | return $node; 60 | } 61 | 62 | $name = $node->name; 63 | $attributes = $node->getAttributesAsRuntimeArrayString(); 64 | 65 | $output = '<' . '?php $blaze_memoized_key = \Livewire\Blaze\Memoizer\Memo::key("' . $name . '", ' . $attributes . '); ?>'; 66 | $output .= '<' . '?php if (! \Livewire\Blaze\Memoizer\Memo::has($blaze_memoized_key)) : ?>'; 67 | $output .= '<' . '?php ob_start(); ?>'; 68 | $output .= $node->render(); 69 | $output .= '<' . '?php \Livewire\Blaze\Memoizer\Memo::put($blaze_memoized_key, ob_get_clean()); ?>'; 70 | $output .= '<' . '?php endif; ?>'; 71 | $output .= '<' . '?php echo \Livewire\Blaze\Memoizer\Memo::get($blaze_memoized_key); ?>'; 72 | 73 | return new TextNode($output); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /tests/BlazeTest.php: -------------------------------------------------------------------------------- 1 | anonymousComponentNamespace('', 'x'); 9 | app('blade.compiler')->anonymousComponentPath(__DIR__ . '/fixtures/components'); 10 | 11 | // Add view path for our test pages 12 | View::addLocation(__DIR__ . '/fixtures/pages'); 13 | }); 14 | 15 | it('renders foldable components as optimized html through blade facade', function () { 16 | // This is still outside-in - we're using Laravel's Blade facade, not calling Blaze directly 17 | $template = ' 18 |
19 |

Integration Test

20 | Save Changes 21 | 22 | 23 | 24 |
'; 25 | 26 | $rendered = \Illuminate\Support\Facades\Blade::render($template); 27 | 28 | // Foldable components should be folded to optimized HTML 29 | expect($rendered)->toContain(''); 30 | expect($rendered)->toContain('
'); 31 | expect($rendered)->toContain('
Success!
'); 32 | 33 | expect($rendered)->not->toContain(''); 34 | expect($rendered)->not->toContain(''); 35 | expect($rendered)->not->toContain('toContain('
'); 39 | expect($rendered)->toContain('

Integration Test

'); 40 | }); 41 | 42 | it('leaves unfoldable components unchanged through blade facade', function () { 43 | $template = '
Test Button
'; 44 | $rendered = \Illuminate\Support\Facades\Blade::render($template); 45 | 46 | // Unfoldable component should render normally (not folded) 47 | expect($rendered)->toContain(''); 48 | expect($rendered)->not->toContain(''); 49 | 50 | // Just verify it renders normally 51 | }); 52 | 53 | it('throws exception for components with invalid foldable usage', function () { 54 | expect(fn() => \Illuminate\Support\Facades\Blade::render('Test')) 55 | ->toThrow(\Livewire\Blaze\Exceptions\InvalidBlazeFoldUsageException::class); 56 | }); 57 | 58 | it('preserves slot content when folding components', function () { 59 | $template = '

Dynamic Title

Dynamic content with emphasis

'; 60 | $rendered = \Illuminate\Support\Facades\Blade::render($template); 61 | 62 | // Should preserve all slot content in folded output 63 | expect($rendered)->toContain('

Dynamic Title

'); 64 | expect($rendered)->toContain('

Dynamic content with emphasis

'); 65 | expect($rendered)->toContain('
'); 66 | expect($rendered)->not->toContain(''); 67 | }); 68 | 69 | }); 70 | -------------------------------------------------------------------------------- /tests/fixtures/components/avatar.blade.php: -------------------------------------------------------------------------------- 1 | @blaze 2 | 3 | @props([ 4 | 'iconVariant' => 'solid', 5 | 'initials' => null, 6 | 'tooltip' => null, 7 | 'circle' => null, 8 | 'color' => null, 9 | 'badge' => null, 10 | 'name' => null, 11 | 'icon' => null, 12 | 'size' => 'md', 13 | 'src' => null, 14 | 'href' => null, 15 | 'alt' => null, 16 | 'as' => 'div', 17 | ]) 18 | 19 | @php 20 | if ($name && ! $initials) { 21 | $parts = explode(' ', trim($name)); 22 | 23 | if (false) { 24 | $initials = strtoupper(mb_substr($parts[0], 0, 1)); 25 | } else { 26 | // Remove empty strings from the array... 27 | $parts = collect($parts)->filter()->values()->all(); 28 | 29 | if (count($parts) > 1) { 30 | $initials = strtoupper(mb_substr($parts[0], 0, 1) . mb_substr($parts[1], 0, 1)); 31 | } else if (count($parts) === 1) { 32 | $initials = strtoupper(mb_substr($parts[0], 0, 1)) . strtolower(mb_substr($parts[0], 1, 1)); 33 | } 34 | } 35 | } 36 | 37 | if ($name && $tooltip === true) { 38 | $tooltip = $name; 39 | } 40 | 41 | $hasTextContent = $icon ?? $initials ?? $slot->isNotEmpty(); 42 | 43 | // If there's no text content, we'll fallback to using the user icon otherwise there will be an empty white square... 44 | if (! $hasTextContent) { 45 | $icon = 'user'; 46 | $hasTextContent = true; 47 | } 48 | 49 | // Be careful not to change the order of these colors. 50 | // They're used in the hash function below and changing them would change actual user avatar colors that they might have grown to identify with. 51 | $colors = ['red', 'orange', 'amber', 'yellow', 'lime', 'green', 'emerald', 'teal', 'cyan', 'sky', 'blue', 'indigo', 'violet', 'purple', 'fuchsia', 'pink', 'rose']; 52 | 53 | if ($hasTextContent && $color === 'auto') { 54 | $colorSeed = false ?? $name ?? $icon ?? $initials ?? $slot; 55 | $hash = crc32((string) $colorSeed); 56 | $color = $colors[$hash % count($colors)]; 57 | } 58 | 59 | $classes = ''; 60 | 61 | $iconClasses = ''; 62 | 63 | $badgeColor = false ?: (is_object($badge) ? false : null); 64 | $badgeCircle = false ?: (is_object($badge) ? false : null); 65 | $badgePosition = false ?: (is_object($badge) ? false : null); 66 | $badgeVariant = false ?: (is_object($badge) ? false : null); 67 | 68 | $badgeClasses = ''; 69 | 70 | $label = $alt ?? $name; 71 | @endphp 72 | 73 | 74 | 75 | 76 | {{ $alt ?? $name }} 77 | 78 | 79 | 80 | {{ $initials ?? $slot }} 81 | 82 | 83 | 84 |
attributes->class($badgeClasses) }} aria-hidden="true">{{ $badge }}
85 | 86 | 87 | 88 |
89 |
90 | -------------------------------------------------------------------------------- /src/Parser/Parser.php: -------------------------------------------------------------------------------- 1 | $this->handleTagOpen($token, $stack), 24 | TagSelfCloseToken::class => $this->handleTagSelfClose($token, $stack), 25 | TagCloseToken::class => $this->handleTagClose($token, $stack), 26 | SlotOpenToken::class => $this->handleSlotOpen($token, $stack), 27 | SlotCloseToken::class => $this->handleSlotClose($token, $stack), 28 | TextToken::class => $this->handleText($token, $stack), 29 | default => throw new \RuntimeException('Unknown token type: ' . get_class($token)) 30 | }; 31 | } 32 | 33 | return $stack->getAst(); 34 | } 35 | 36 | protected function handleTagOpen(TagOpenToken $token, ParseStack $stack): void 37 | { 38 | $node = new ComponentNode( 39 | name: $token->namespace . $token->name, 40 | prefix: $token->prefix, 41 | attributes: $token->attributes, 42 | children: [], 43 | selfClosing: false 44 | ); 45 | 46 | $stack->pushContainer($node); 47 | } 48 | 49 | protected function handleTagSelfClose(TagSelfCloseToken $token, ParseStack $stack): void 50 | { 51 | $node = new ComponentNode( 52 | name: $token->namespace . $token->name, 53 | prefix: $token->prefix, 54 | attributes: $token->attributes, 55 | children: [], 56 | selfClosing: true 57 | ); 58 | 59 | $stack->addToRoot($node); 60 | } 61 | 62 | protected function handleTagClose(TagCloseToken $token, ParseStack $stack): void 63 | { 64 | $stack->popContainer(); 65 | } 66 | 67 | protected function handleSlotOpen(SlotOpenToken $token, ParseStack $stack): void 68 | { 69 | $node = new SlotNode( 70 | name: $token->name ?? '', 71 | attributes: $token->attributes, 72 | slotStyle: $token->slotStyle, 73 | children: [], 74 | prefix: $token->prefix, 75 | closeHasName: false, 76 | ); 77 | 78 | $stack->pushContainer($node); 79 | } 80 | 81 | protected function handleSlotClose(SlotCloseToken $token, ParseStack $stack): void 82 | { 83 | $closed = $stack->popContainer(); 84 | if ($closed instanceof SlotNode && $closed->slotStyle === 'short') { 85 | // If tokenizer captured a :name on the close tag, mark it 86 | if (! empty($token->name)) { 87 | $closed->closeHasName = true; 88 | } 89 | } 90 | } 91 | 92 | protected function handleText(TextToken $token, ParseStack $stack): void 93 | { 94 | // Always preserve text content, including whitespace... 95 | $node = new TextNode(content: $token->content); 96 | 97 | $stack->addToRoot($node); 98 | } 99 | } -------------------------------------------------------------------------------- /src/Exceptions/InvalidBlazeFoldUsageException.php: -------------------------------------------------------------------------------- 1 | componentPath = $componentPath; 14 | $this->problematicPattern = $problematicPattern; 15 | 16 | $message = "Invalid @blaze fold usage in component '{$componentPath}': {$reason}"; 17 | 18 | parent::__construct($message); 19 | } 20 | 21 | public function getComponentPath(): string 22 | { 23 | return $this->componentPath; 24 | } 25 | 26 | public function getProblematicPattern(): string 27 | { 28 | return $this->problematicPattern; 29 | } 30 | 31 | public static function forAware(string $componentPath): self 32 | { 33 | return new self( 34 | $componentPath, 35 | '@aware', 36 | 'Components with @aware should not use @blaze fold as they depend on parent component state' 37 | ); 38 | } 39 | 40 | public static function forErrors(string $componentPath): self 41 | { 42 | return new self( 43 | $componentPath, 44 | '\\$errors', 45 | 'Components accessing $errors should not use @blaze fold as errors are request-specific' 46 | ); 47 | } 48 | 49 | public static function forSession(string $componentPath): self 50 | { 51 | return new self( 52 | $componentPath, 53 | 'session\\(', 54 | 'Components using session() should not use @blaze fold as session data can change' 55 | ); 56 | } 57 | 58 | public static function forError(string $componentPath): self 59 | { 60 | return new self( 61 | $componentPath, 62 | '@error\\(', 63 | 'Components with @error directives should not use @blaze fold as errors are request-specific' 64 | ); 65 | } 66 | 67 | public static function forCsrf(string $componentPath): self 68 | { 69 | return new self( 70 | $componentPath, 71 | '@csrf', 72 | 'Components with @csrf should not use @blaze fold as CSRF tokens are request-specific' 73 | ); 74 | } 75 | 76 | public static function forAuth(string $componentPath): self 77 | { 78 | return new self( 79 | $componentPath, 80 | 'auth\\(\\)', 81 | 'Components using auth() should not use @blaze fold as authentication state can change' 82 | ); 83 | } 84 | 85 | public static function forRequest(string $componentPath): self 86 | { 87 | return new self( 88 | $componentPath, 89 | 'request\\(\\)', 90 | 'Components using request() should not use @blaze fold as request data varies' 91 | ); 92 | } 93 | 94 | public static function forOld(string $componentPath): self 95 | { 96 | return new self( 97 | $componentPath, 98 | 'old\\(', 99 | 'Components using old() should not use @blaze fold as old input is request-specific' 100 | ); 101 | } 102 | 103 | public static function forOnce(string $componentPath): self 104 | { 105 | return new self( 106 | $componentPath, 107 | '@once', 108 | 'Components with @once should not use @blaze fold as @once maintains runtime state' 109 | ); 110 | } 111 | 112 | } 113 | -------------------------------------------------------------------------------- /tests/CacheInvalidationTest.php: -------------------------------------------------------------------------------- 1 | anonymousComponentNamespace('', 'x'); 6 | app('blade.compiler')->anonymousComponentPath(__DIR__ . '/fixtures/components'); 7 | }); 8 | 9 | function compileWithFrontmatter(string $input): string { 10 | return app('blaze')->collectAndAppendFrontMatter($input, function($template) { 11 | return app('blaze')->compile($template); 12 | }); 13 | } 14 | 15 | it('embeds folded component metadata as frontmatter', function () { 16 | $input = 'Save'; 17 | $output = compileWithFrontmatter($input); 18 | 19 | // Check that frontmatter exists with component metadata 20 | expect($output)->toContain('<'.'?php # [BlazeFolded]:'); 21 | expect($output)->toContain('{'.__DIR__ . '/fixtures/components/button.blade.php}'); 22 | expect($output)->toContain('{button}'); 23 | }); 24 | 25 | it('dispatches ComponentFolded events during compilation', function () { 26 | \Illuminate\Support\Facades\Event::fake([\Livewire\Blaze\Events\ComponentFolded::class]); 27 | 28 | $input = 'Save'; 29 | app('blaze')->compile($input); 30 | 31 | \Illuminate\Support\Facades\Event::assertDispatched(\Livewire\Blaze\Events\ComponentFolded::class, function($event) { 32 | return $event->name === 'button' 33 | && str_contains($event->path, 'button.blade.php') 34 | && is_int($event->filemtime); 35 | }); 36 | }); 37 | 38 | it('parses frontmatter correctly', function () { 39 | $frontMatter = new \Livewire\Blaze\FrontMatter(); 40 | 41 | // First, let's see what the actual frontmatter looks like 42 | $input = 'Save'; 43 | $output = compileWithFrontmatter($input); 44 | 45 | // Debug: show what the actual output looks like 46 | // echo "Actual output: " . $output . "\n"; 47 | 48 | $parsed = $frontMatter->parseFromTemplate($output); 49 | expect($parsed)->toHaveCount(1); 50 | }); 51 | 52 | it('detects expired folded dependencies', function () { 53 | $frontMatter = new \Livewire\Blaze\FrontMatter(); 54 | 55 | // Get the actual generated frontmatter format first 56 | $input = 'Save'; 57 | $actualOutput = compileWithFrontmatter($input); 58 | 59 | // Now test with current filemtime (not expired) 60 | expect($frontMatter->sourceContainsExpiredFoldedDependencies($actualOutput))->toBeFalse(); 61 | 62 | // Test with old filemtime by manually creating expired data 63 | $buttonPath = __DIR__ . '/fixtures/components/button.blade.php'; 64 | $currentFilemtime = filemtime($buttonPath); 65 | $oldFilemtime = $currentFilemtime - 3600; // 1 hour ago 66 | 67 | // Create expired frontmatter manually 68 | $expiredFrontmatter = "\n"; 69 | $expiredOutput = $expiredFrontmatter . ""; 70 | 71 | // Check parsing 72 | $parsed = $frontMatter->parseFromTemplate($expiredOutput); 73 | expect($parsed)->toHaveCount(1); 74 | expect((int)$parsed[0][3])->toBeLessThan($currentFilemtime); // old filemtime should be less than current 75 | 76 | expect($frontMatter->sourceContainsExpiredFoldedDependencies($expiredOutput))->toBeTrue(); 77 | 78 | // Test with non-existent file (expired) 79 | $missingOutput = str_replace($buttonPath, '/path/that/does/not/exist.blade.php', $actualOutput); 80 | expect($frontMatter->sourceContainsExpiredFoldedDependencies($missingOutput))->toBeTrue(); 81 | }); 82 | }); -------------------------------------------------------------------------------- /src/BlazeServiceProvider.php: -------------------------------------------------------------------------------- 1 | registerBlazeManager(); 19 | $this->registerBlazeDirectiveFallbacks(); 20 | $this->registerBladeMacros(); 21 | $this->interceptBladeCompilation(); 22 | $this->interceptViewCacheInvalidation(); 23 | } 24 | 25 | protected function registerBlazeManager(): void 26 | { 27 | $bladeService = new BladeService; 28 | 29 | $this->app->singleton(BlazeManager::class, fn () => new BlazeManager( 30 | new Tokenizer, 31 | new Parser, 32 | new Walker, 33 | new Folder( 34 | renderBlade: fn ($blade) => $bladeService->isolatedRender($blade), 35 | renderNodes: fn ($nodes) => implode('', array_map(fn ($n) => $n->render(), $nodes)), 36 | componentNameToPath: fn ($name) => $bladeService->componentNameToPath($name), 37 | ), 38 | new Memoizer( 39 | componentNameToPath: fn ($name) => $bladeService->componentNameToPath($name), 40 | ), 41 | )); 42 | 43 | $this->app->alias(BlazeManager::class, Blaze::class); 44 | 45 | $this->app->bind('blaze', fn ($app) => $app->make(BlazeManager::class)); 46 | } 47 | 48 | protected function registerBlazeDirectiveFallbacks(): void 49 | { 50 | Blade::directive('unblaze', function ($expression) { 51 | return '' 52 | . '<'.'?php $__getScope = fn($scope = []) => $scope; ?>' 53 | . '<'.'?php if (isset($scope)) $__scope = $scope; ?>' 54 | . '<'.'?php $scope = $__getScope('.$expression.'); ?>'; 55 | }); 56 | 57 | Blade::directive('endunblaze', function () { 58 | return '<'.'?php if (isset($__scope)) { $scope = $__scope; unset($__scope); } ?>'; 59 | }); 60 | 61 | BlazeDirective::registerFallback(); 62 | } 63 | 64 | protected function registerBladeMacros(): void 65 | { 66 | $this->app->make('view')->macro('pushConsumableComponentData', function ($data) { 67 | $this->componentStack[] = new \Illuminate\Support\HtmlString(''); 68 | 69 | $this->componentData[$this->currentComponent()] = $data; 70 | }); 71 | 72 | $this->app->make('view')->macro('popConsumableComponentData', function () { 73 | array_pop($this->componentStack); 74 | }); 75 | } 76 | 77 | protected function interceptBladeCompilation(): void 78 | { 79 | $blaze = app(BlazeManager::class); 80 | 81 | (new BladeService)->earliestPreCompilationHook(function ($input) use ($blaze) { 82 | if ($blaze->isDisabled()) return $input; 83 | 84 | if ((new BladeService)->containsLaravelExceptionView($input)) return $input; 85 | 86 | return $blaze->collectAndAppendFrontMatter($input, function ($input) use ($blaze) { 87 | return $blaze->compile($input); 88 | }); 89 | }); 90 | } 91 | 92 | protected function interceptViewCacheInvalidation(): void 93 | { 94 | $blaze = app(BlazeManager::class); 95 | 96 | (new BladeService)->viewCacheInvalidationHook(function ($view, $invalidate) use ($blaze) { 97 | if ($blaze->isDisabled()) return; 98 | 99 | if ($blaze->viewContainsExpiredFrontMatter($view)) { 100 | $invalidate(); 101 | } 102 | }); 103 | } 104 | 105 | public function boot(): void 106 | { 107 | // Bootstrap services 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /src/Unblaze.php: -------------------------------------------------------------------------------- 1 | directive('unblaze', function ($expression) use (&$expressionsByToken) { 29 | $token = str()->random(10); 30 | 31 | $expressionsByToken[$token] = $expression; 32 | 33 | return '[STARTUNBLAZE:'.$token.']'; 34 | }); 35 | 36 | $compiler->directive('endunblaze', function () { 37 | return '[ENDUNBLAZE]'; 38 | }); 39 | 40 | $result = $compiler->compileStatementsMadePublic($template); 41 | 42 | $result = preg_replace_callback('/(\[STARTUNBLAZE:([0-9a-zA-Z]+)\])(.*?)(\[ENDUNBLAZE\])/s', function ($matches) use (&$expressionsByToken) { 43 | $token = $matches[2]; 44 | $expression = $expressionsByToken[$token]; 45 | $innerContent = $matches[3]; 46 | 47 | static::$unblazeReplacements[$token] = $innerContent; 48 | 49 | return '' 50 | . '[STARTCOMPILEDUNBLAZE:'.$token.']' 51 | . '<'.'?php \Livewire\Blaze\Unblaze::storeScope("'.$token.'", '.$expression.') ?>' 52 | . '[ENDCOMPILEDUNBLAZE]'; 53 | }, $result); 54 | 55 | return $result; 56 | } 57 | 58 | public static function replaceUnblazePrecompiledDirectives(string $template) 59 | { 60 | if (str_contains($template, '[STARTCOMPILEDUNBLAZE')) { 61 | $template = preg_replace_callback('/(\[STARTCOMPILEDUNBLAZE:([0-9a-zA-Z]+)\])(.*?)(\[ENDCOMPILEDUNBLAZE\])/s', function ($matches) use (&$expressionsByToken) { 62 | $token = $matches[2]; 63 | 64 | $innerContent = static::$unblazeReplacements[$token]; 65 | 66 | $scope = static::$unblazeScopes[$token]; 67 | 68 | $runtimeScopeString = var_export($scope, true); 69 | 70 | return '' 71 | . '<'.'?php if (isset($scope)) $__scope = $scope; ?>' 72 | . '<'.'?php $scope = '.$runtimeScopeString.'; ?>' 73 | . $innerContent 74 | . '<'.'?php if (isset($__scope)) { $scope = $__scope; unset($__scope); } ?>'; 75 | }, $template); 76 | } 77 | 78 | return $template; 79 | } 80 | 81 | public static function getHackedBladeCompiler() 82 | { 83 | $instance = new class ( 84 | app('files'), 85 | storage_path('framework/views'), 86 | ) extends \Illuminate\View\Compilers\BladeCompiler { 87 | /** 88 | * Make this method public... 89 | */ 90 | public function compileStatementsMadePublic($template) 91 | { 92 | return $this->compileStatements($template); 93 | } 94 | 95 | /** 96 | * Tweak this method to only process custom directives so we 97 | * can restrict rendering solely to @island related directives... 98 | */ 99 | protected function compileStatement($match) 100 | { 101 | if (str_contains($match[1], '@')) { 102 | $match[0] = isset($match[3]) ? $match[1].$match[3] : $match[1]; 103 | } elseif (isset($this->customDirectives[$match[1]])) { 104 | $match[0] = $this->callCustomDirective($match[1], Arr::get($match, 3)); 105 | } elseif (method_exists($this, $method = 'compile'.ucfirst($match[1]))) { 106 | // Don't process through built-in directive methods... 107 | // $match[0] = $this->$method(Arr::get($match, 3)); 108 | 109 | // Just return the original match... 110 | return $match[0]; 111 | } else { 112 | return $match[0]; 113 | } 114 | 115 | return isset($match[3]) ? $match[0] : $match[0].$match[2]; 116 | } 117 | }; 118 | 119 | return $instance; 120 | } 121 | } -------------------------------------------------------------------------------- /src/BlazeManager.php: -------------------------------------------------------------------------------- 1 | foldedEvents[] = $event; 33 | }); 34 | } 35 | 36 | public function flushFoldedEvents() 37 | { 38 | return tap($this->foldedEvents, function ($events) { 39 | $this->foldedEvents = []; 40 | 41 | return $events; 42 | }); 43 | } 44 | 45 | public function collectAndAppendFrontMatter($template, $callback) 46 | { 47 | $this->flushFoldedEvents(); 48 | 49 | $output = $callback($template); 50 | 51 | $frontmatter = (new FrontMatter)->compileFromEvents( 52 | $this->flushFoldedEvents() 53 | ); 54 | 55 | return $frontmatter . $output; 56 | } 57 | 58 | public function viewContainsExpiredFrontMatter($view): bool 59 | { 60 | $path = $view->getPath(); 61 | 62 | if (isset($this->expiredMemo[$path])) { 63 | return $this->expiredMemo[$path]; 64 | } 65 | 66 | $compiler = $view->getEngine()->getCompiler(); 67 | $compiled = $compiler->getCompiledPath($path); 68 | $expired = $compiler->isExpired($path); 69 | 70 | $isExpired = false; 71 | 72 | if (! $expired) { 73 | $contents = file_get_contents($compiled); 74 | 75 | $isExpired = (new FrontMatter)->sourceContainsExpiredFoldedDependencies($contents); 76 | } 77 | 78 | $this->expiredMemo[$path] = $isExpired; 79 | 80 | return $isExpired; 81 | } 82 | 83 | public function compile(string $template): string 84 | { 85 | // Protect verbatim blocks before tokenization 86 | $template = (new BladeService)->preStoreVerbatimBlocks($template); 87 | 88 | $tokens = $this->tokenizer->tokenize($template); 89 | 90 | $ast = $this->parser->parse($tokens); 91 | 92 | $dataStack = []; 93 | 94 | $ast = $this->walker->walk( 95 | nodes: $ast, 96 | preCallback: function ($node) use (&$dataStack) { 97 | if ($node instanceof ComponentNode) { 98 | $node->setParentsAttributes($dataStack); 99 | } 100 | 101 | if (($node instanceof ComponentNode) && !empty($node->children)) { 102 | array_push($dataStack, $node->attributes); 103 | } 104 | 105 | return $node; 106 | }, 107 | postCallback: function ($node) use (&$dataStack) { 108 | if (($node instanceof ComponentNode) && !empty($node->children)) { 109 | array_pop($dataStack); 110 | } 111 | 112 | return $this->memoizer->memoize($this->folder->fold($node)); 113 | }, 114 | ); 115 | 116 | $output = $this->render($ast); 117 | 118 | (new BladeService)->deleteTemporaryCacheDirectory(); 119 | 120 | return $output; 121 | } 122 | 123 | public function render(array $nodes): string 124 | { 125 | return implode('', array_map(fn ($n) => $n->render(), $nodes)); 126 | } 127 | 128 | public function isEnabled() 129 | { 130 | return $this->enabled; 131 | } 132 | 133 | public function isDisabled() 134 | { 135 | return ! $this->enabled; 136 | } 137 | 138 | public function enable() 139 | { 140 | $this->enabled = true; 141 | } 142 | 143 | public function disable() 144 | { 145 | $this->enabled = false; 146 | } 147 | 148 | public function debug() 149 | { 150 | $this->debug = true; 151 | } 152 | 153 | public function isDebugging() 154 | { 155 | return $this->debug; 156 | } 157 | 158 | public function tokenizer(): Tokenizer 159 | { 160 | return $this->tokenizer; 161 | } 162 | 163 | public function parser(): Parser 164 | { 165 | return $this->parser; 166 | } 167 | 168 | public function folder(): Folder 169 | { 170 | return $this->folder; 171 | } 172 | } 173 | -------------------------------------------------------------------------------- /tests/InfiniteRecursionTest.php: -------------------------------------------------------------------------------- 1 | anonymousComponentNamespace('', 'x'); 7 | app('blade.compiler')->anonymousComponentPath(__DIR__ . '/fixtures/components'); 8 | }); 9 | 10 | it('should handle dynamic attributes without infinite recursion', function () { 11 | $template = 'Submit'; 12 | 13 | // This should complete without hanging or crashing 14 | $rendered = \Illuminate\Support\Facades\Blade::render($template); 15 | 16 | expect($rendered)->toContain('toContain('type="submit"'); 18 | expect($rendered)->toContain('class="btn-primary"'); 19 | expect($rendered)->toContain('Submit'); 20 | expect($rendered)->not->toContain('toContain('
'); 30 | expect($rendered)->toContain('
'); 31 | expect($rendered)->toContain('Nested alert!'); 32 | expect($rendered)->not->toContain(''); 33 | expect($rendered)->not->toContain('toContain('
'); 48 | expect($rendered)->toContain('Button 1'); 49 | expect($rendered)->toContain('Button 2'); 50 | expect($rendered)->toContain('Alert in card'); 51 | expect($rendered)->not->toContain(''); 52 | expect($rendered)->not->toContain('not->toContain('toContain('
'); 75 | expect($rendered)->toContain('

Outer Card

'); 76 | expect($rendered)->toContain('

Inner Card

'); 77 | expect($rendered)->toContain('Nested Button'); 78 | expect($rendered)->toContain('Deeply nested alert'); 79 | expect($rendered)->toContain('Outer Button'); 80 | expect($rendered)->not->toContain(''); 81 | expect($rendered)->not->toContain('not->toContain('toContain('toContain('type="submit"'); 101 | expect($rendered)->toContain('class="btn-primary btn-large"'); 102 | expect($rendered)->toContain('data-action="save"'); 103 | expect($rendered)->toContain('aria-label="Save changes"'); 104 | expect($rendered)->toContain('Save Changes'); 105 | expect($rendered)->not->toContain('Complex'; 111 | 112 | // This should complete without hanging or crashing 113 | $rendered = \Illuminate\Support\Facades\Blade::render($template); 114 | 115 | expect($rendered)->toContain('toContain('Complex'); 117 | expect($rendered)->not->toContain('anonymousComponentPath($basePath); 9 | app('blade.compiler')->anonymousComponentPath($basePath, 'fixtures'); 10 | }); 11 | 12 | describe('namespaced', function () { 13 | it('gets the correct path for namespaced direct component files', function () { 14 | $input = 'fixtures::button'; 15 | $expected = __DIR__ . '/fixtures/components/button.blade.php'; 16 | 17 | expect((new BladeService)->componentNameToPath($input))->toBe($expected); 18 | }); 19 | 20 | it('prefers namespaced direct component file over index.blade.php for root components', function () { 21 | $input = 'fixtures::card'; 22 | $expected = __DIR__ . '/fixtures/components/card.blade.php'; 23 | 24 | expect((new BladeService)->componentNameToPath($input))->toBe($expected); 25 | }); 26 | 27 | it('gets the correct path for namespaced nested components', function () { 28 | $input = 'fixtures::form.input'; 29 | $expected = __DIR__ . '/fixtures/components/form/input.blade.php'; 30 | 31 | expect((new BladeService)->componentNameToPath($input))->toBe($expected); 32 | }); 33 | 34 | it('gets the correct path for namespaced deeply nested components', function () { 35 | $input = 'fixtures::form.fields.text'; 36 | $expected = __DIR__ . '/fixtures/components/form/fields/text.blade.php'; 37 | 38 | expect((new BladeService)->componentNameToPath($input))->toBe($expected); 39 | }); 40 | 41 | it('gets the correct path for namespaced root components using index.blade.php', function () { 42 | $input = 'fixtures::form'; 43 | $expected = __DIR__ . '/fixtures/components/form/index.blade.php'; 44 | 45 | expect((new BladeService)->componentNameToPath($input))->toBe($expected); 46 | }); 47 | 48 | it('gets the correct path for namespaced root components using same-name.blade.php', function () { 49 | $input = 'fixtures::panel'; 50 | $expected = __DIR__ . '/fixtures/components/panel/panel.blade.php'; 51 | 52 | expect((new BladeService)->componentNameToPath($input))->toBe($expected); 53 | }); 54 | 55 | it('gets the correct path for namespaced nested root components using same-name.blade.php', function () { 56 | $input = 'fixtures::kanban.comments'; 57 | $expected = __DIR__ . '/fixtures/components/kanban/comments/comments.blade.php'; 58 | 59 | expect((new BladeService)->componentNameToPath($input))->toBe($expected); 60 | }); 61 | 62 | it('handles non-existent namespaced components gracefully', function () { 63 | $input = 'fixtures::nonexistent.component'; 64 | 65 | expect((new BladeService)->componentNameToPath($input))->toBe(''); 66 | }); 67 | }); 68 | 69 | describe('non-namespaced', function () { 70 | it('gets the correct path for direct component files', function () { 71 | $input = 'button'; 72 | $expected = __DIR__ . '/fixtures/components/button.blade.php'; 73 | 74 | expect((new BladeService)->componentNameToPath($input))->toBe($expected); 75 | }); 76 | 77 | it('prefers direct component file over index.blade.php for root components', function () { 78 | $input = 'card'; 79 | $expected = __DIR__ . '/fixtures/components/card.blade.php'; 80 | 81 | expect((new BladeService)->componentNameToPath($input))->toBe($expected); 82 | }); 83 | 84 | it('gets the correct path for nested components', function () { 85 | $input = 'form.input'; 86 | $expected = __DIR__ . '/fixtures/components/form/input.blade.php'; 87 | 88 | expect((new BladeService)->componentNameToPath($input))->toBe($expected); 89 | }); 90 | 91 | it('gets the correct path for deeply nested components', function () { 92 | $input = 'form.fields.text'; 93 | $expected = __DIR__ . '/fixtures/components/form/fields/text.blade.php'; 94 | 95 | expect((new BladeService)->componentNameToPath($input))->toBe($expected); 96 | }); 97 | 98 | it('gets the correct path for root components using index.blade.php', function () { 99 | $input = 'form'; 100 | $expected = __DIR__ . '/fixtures/components/form/index.blade.php'; 101 | 102 | expect((new BladeService)->componentNameToPath($input))->toBe($expected); 103 | }); 104 | 105 | it('gets the correct path for root components using same-name.blade.php', function () { 106 | $input = 'panel'; 107 | $expected = __DIR__ . '/fixtures/components/panel/panel.blade.php'; 108 | 109 | expect((new BladeService)->componentNameToPath($input))->toBe($expected); 110 | }); 111 | 112 | it('gets the correct path for nested root components using same-name.blade.php', function () { 113 | $input = 'kanban.comments'; 114 | $expected = __DIR__ . '/fixtures/components/kanban/comments/comments.blade.php'; 115 | 116 | expect((new BladeService)->componentNameToPath($input))->toBe($expected); 117 | }); 118 | 119 | it('handles non-existent components gracefully', function () { 120 | $input = 'nonexistent.component'; 121 | 122 | expect((new BladeService)->componentNameToPath($input))->toBe(''); 123 | }); 124 | }); 125 | }); 126 | -------------------------------------------------------------------------------- /tests/ExcerciseTest.php: -------------------------------------------------------------------------------- 1 | tokenizer()->tokenize($input); 6 | $ast = app('blaze')->parser()->parse($tokens); 7 | $output = implode('', array_map(fn ($n) => $n->render(), $ast)); 8 | return $output; 9 | } 10 | 11 | it('simple component with static attributes', function () { 12 | $input = 'Click Me'; 13 | expect(compileBlade($input))->toBe($input); 14 | }); 15 | 16 | it('self-closing components', function () { 17 | $input = ''; 18 | expect(compileBlade($input))->toBe($input); 19 | }); 20 | 21 | it('nested components', function () { 22 | $input = 'SaveCancel'; 23 | expect(compileBlade($input))->toBe($input); 24 | }); 25 | 26 | it('components with complex nesting and text', function () { 27 | $input = ' 28 | 29 | Home 30 | About 31 | 32 |
33 |

Welcome back!

34 | 35 | Stats 36 | 37 |

Your dashboard content here

38 |
39 |
40 |
41 |
'; 42 | expect(compileBlade($input))->toBe($input); 43 | }); 44 | 45 | it('components with various attribute formats', function () { 46 | $input = 'Login'; 47 | expect(compileBlade($input))->toBe($input); 48 | }); 49 | 50 | it('components with quoted attributes containing spaces', function () { 51 | $input = ''; 52 | expect(compileBlade($input))->toBe($input); 53 | }); 54 | 55 | it('mixed regular HTML and components', function () { 56 | $input = '
57 |

Page Title

58 | Operation completed! 59 |

Some regular HTML content

60 | Click me 61 |
'; 62 | expect(compileBlade($input))->toBe($input); 63 | }); 64 | 65 | it('flux namespace components', function () { 66 | $input = 'Flux Button'; 67 | expect(compileBlade($input))->toBe($input); 68 | }); 69 | 70 | it('flux self-closing components', function () { 71 | $input = ''; 72 | expect(compileBlade($input))->toBe($input); 73 | }); 74 | 75 | it('x: namespace components', function () { 76 | $input = ' 77 | Delete 78 | Cancel 79 | '; 80 | expect(compileBlade($input))->toBe($input); 81 | }); 82 | 83 | it('standard slot syntax', function () { 84 | $input = ' 85 |

Modal Title

86 |
87 | OK 88 | 89 |

Modal content goes here

90 |
'; 91 | expect(compileBlade($input))->toBe($input); 92 | }); 93 | 94 | it('short slot syntax', function () { 95 | $input = ' 96 |

Card Title

97 | 98 | Footer text 99 | 100 |

Card body content

101 |
'; 102 | expect(compileBlade($input))->toBe($input); 103 | }); 104 | 105 | it('mixed slot syntaxes', function () { 106 | $input = ' 107 | Page Title 108 | 109 | Home 110 | Settings 111 | 112 |
Main content
113 |
'; 114 | expect(compileBlade($input))->toBe($input); 115 | }); 116 | 117 | it('slots with attributes', function () { 118 | $input = ' 119 | 120 |

Styled Header

121 |
122 | 123 | Save 124 | Cancel 125 | 126 |
'; 127 | expect(compileBlade($input))->toBe($input); 128 | }); 129 | 130 | it('deeply nested components and slots', function () { 131 | $input = ' 132 | 133 | 134 | 135 | Dashboard 136 | Users 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | User Management 145 | 146 | 147 | 148 | 149 | Name 150 | Email 151 | Actions 152 | 153 | 154 | John Doe 155 | john@example.com 156 | 157 | Edit 158 | 159 | 160 | 161 | 162 | 163 | 164 | '; 165 | expect(compileBlade($input))->toBe($input); 166 | }); 167 | 168 | it('components with complex attribute values', function () { 169 | $input = ' 170 | 171 | 172 | Create User 173 | '; 174 | expect(compileBlade($input))->toBe($input); 175 | }); 176 | 177 | it('empty components', function () { 178 | $input = ''; 179 | expect(compileBlade($input))->toBe($input); 180 | }); 181 | 182 | it('components with only whitespace content', function () { 183 | $input = ' 184 | 185 | 186 | '; 187 | expect(compileBlade($input))->toBe($input); 188 | }); 189 | 190 | it('special characters in text content', function () { 191 | $input = ' 192 | if (condition && other_condition) { 193 | console.log("Hello & welcome!"); 194 | return true; 195 | } 196 | '; 197 | expect(compileBlade($input))->toBe($input); 198 | }); 199 | 200 | it('components with hyphenated names', function () { 201 | $input = ' 202 | 203 | 204 | John Doe 205 | john@example.com 206 | 207 | '; 208 | expect(compileBlade($input))->toBe($input); 209 | }); 210 | 211 | it('components with dots in names', function () { 212 | $input = ' 213 | 214 | United States 215 | Canada 216 | '; 217 | expect(compileBlade($input))->toBe($input); 218 | }); 219 | 220 | it('mixed component prefixes in same template', function () { 221 | $input = ' 222 | 223 | 224 | 225 |
226 | 227 | Flux Button 228 | 229 | Mixed prefixes work 230 | 231 | 232 |
233 |
'; 234 | expect(compileBlade($input))->toBe($input); 235 | }); 236 | 237 | it('preserve exact whitespace and formatting', function () { 238 | $input = ' This has lots of spaces '; 239 | expect(compileBlade($input))->toBe($input); 240 | }); 241 | 242 | it('attributes with single quotes', function () { 243 | $input = "Content"; 244 | expect(compileBlade($input))->toBe($input); 245 | }); 246 | 247 | it('attributes with nested quotes', function () { 248 | $input = 'Hover me'; 249 | expect(compileBlade($input))->toBe($input); 250 | }); 251 | }); 252 | -------------------------------------------------------------------------------- /src/BladeService.php: -------------------------------------------------------------------------------- 1 | getTemporaryCachePath(); 22 | 23 | File::ensureDirectoryExists($temporaryCachePath); 24 | 25 | $factory = app('view'); 26 | 27 | [$factory, $restoreFactory] = $this->freezeObjectProperties($factory, [ 28 | 'componentStack' => [], 29 | 'componentData' => [], 30 | 'currentComponentData' => [], 31 | 'slots' => [], 32 | 'slotStack' => [], 33 | 'renderCount' => 0, 34 | ]); 35 | 36 | [$compiler, $restore] = $this->freezeObjectProperties($compiler, [ 37 | 'cachePath' => $temporaryCachePath, 38 | 'rawBlocks', 39 | 'prepareStringsForCompilationUsing' => [ 40 | function ($input) { 41 | if (Unblaze::hasUnblaze($input)) { 42 | $input = Unblaze::processUnblazeDirectives($input); 43 | } 44 | 45 | return $input; 46 | } 47 | ], 48 | 'path' => null, 49 | ]); 50 | 51 | try { 52 | // As we are rendering a string, Blade will generate a view for the string in the cache directory 53 | // and it doesn't use the `cachePath` property. Instead it uses the config `view.compiled` path 54 | // to store the view. Hence why our `temporaryCachePath` won't clean this file up. To remove 55 | // the file, we can pass `deleteCachedView: true` to the render method... 56 | $result = $compiler->render($template, deleteCachedView: true); 57 | 58 | $result = Unblaze::replaceUnblazePrecompiledDirectives($result); 59 | } finally { 60 | $restore(); 61 | $restoreFactory(); 62 | } 63 | 64 | return $result; 65 | } 66 | 67 | public function deleteTemporaryCacheDirectory() 68 | { 69 | File::deleteDirectory($this->getTemporaryCachePath()); 70 | } 71 | 72 | public function containsLaravelExceptionView(string $input): bool 73 | { 74 | return str_contains($input, 'laravel-exceptions'); 75 | } 76 | 77 | public function earliestPreCompilationHook(callable $callback) 78 | { 79 | app()->booted(function () use ($callback) { 80 | app('blade.compiler')->prepareStringsForCompilationUsing(function ($input) use ($callback) { 81 | $output = $callback($input); 82 | 83 | return $output; 84 | }); 85 | }); 86 | } 87 | 88 | public function preStoreVerbatimBlocks(string $input): string 89 | { 90 | $compiler = app('blade.compiler'); 91 | 92 | $reflection = new \ReflectionClass($compiler); 93 | $storeVerbatimBlocks = $reflection->getMethod('storeVerbatimBlocks'); 94 | $storeVerbatimBlocks->setAccessible(true); 95 | 96 | return $storeVerbatimBlocks->invoke($compiler, $input); 97 | } 98 | 99 | public function restoreVerbatimBlocks(string $input): string 100 | { 101 | $compiler = app('blade.compiler'); 102 | 103 | $reflection = new \ReflectionClass($compiler); 104 | $restoreRawBlocks = $reflection->getMethod('restoreRawBlocks'); 105 | $restoreRawBlocks->setAccessible(true); 106 | 107 | return $restoreRawBlocks->invoke($compiler, $input); 108 | } 109 | 110 | public function viewCacheInvalidationHook(callable $callback) 111 | { 112 | Event::listen('composing:*', function ($event, $params) use ($callback) { 113 | $view = $params[0]; 114 | 115 | if (! $view instanceof \Illuminate\View\View) { 116 | return; 117 | } 118 | 119 | $invalidate = fn () => app('blade.compiler')->compile($view->getPath()); 120 | 121 | $callback($view, $invalidate); 122 | }); 123 | } 124 | 125 | public function componentNameToPath($name): string 126 | { 127 | $compiler = app('blade.compiler'); 128 | $viewFinder = app('view.finder'); 129 | 130 | $reflection = new \ReflectionClass($compiler); 131 | $pathsProperty = $reflection->getProperty('anonymousComponentPaths'); 132 | $pathsProperty->setAccessible(true); 133 | $paths = $pathsProperty->getValue($compiler) ?? []; 134 | 135 | // Handle namespaced components... 136 | if (str_contains($name, '::')) { 137 | [$namespace, $componentName] = explode('::', $name, 2); 138 | $componentPath = str_replace('.', '/', $componentName); 139 | 140 | // Look for namespaced anonymous component... 141 | foreach ($paths as $pathData) { 142 | if (isset($pathData['prefix']) && $pathData['prefix'] === $namespace) { 143 | $basePath = rtrim($pathData['path'], '/'); 144 | 145 | // Try direct component file first (e.g., pages::auth.login -> auth/login.blade.php)... 146 | $fullPath = $basePath . '/' . $componentPath . '.blade.php'; 147 | if (file_exists($fullPath)) { 148 | return $fullPath; 149 | } 150 | 151 | // Try index.blade.php (e.g., pages::auth -> auth/index.blade.php)... 152 | $indexPath = $basePath . '/' . $componentPath . '/index.blade.php'; 153 | if (file_exists($indexPath)) { 154 | return $indexPath; 155 | } 156 | 157 | // Try same-name file (e.g., pages::auth -> auth/auth.blade.php)... 158 | $lastSegment = basename($componentPath); 159 | $sameNamePath = $basePath . '/' . $componentPath . '/' . $lastSegment . '.blade.php'; 160 | if (file_exists($sameNamePath)) { 161 | return $sameNamePath; 162 | } 163 | } 164 | } 165 | 166 | // Fallback to regular namespaced view lookup... 167 | try { 168 | return $viewFinder->find(str_replace('::', '::components.', $name)); 169 | } catch (\Exception $e) { 170 | return ''; 171 | } 172 | } 173 | 174 | // For regular anonymous components, check the registered paths... 175 | $componentPath = str_replace('.', '/', $name); 176 | 177 | // Check each registered anonymous component path (without prefix)... 178 | foreach ($paths as $pathData) { 179 | // Only check paths without a prefix for regular anonymous components... 180 | if (!isset($pathData['prefix']) || $pathData['prefix'] === null) { 181 | $registeredPath = $pathData['path'] ?? $pathData; 182 | 183 | if (is_string($registeredPath)) { 184 | $basePath = rtrim($registeredPath, '/'); 185 | 186 | // Try direct component file first (e.g., form.input -> form/input.blade.php)... 187 | $fullPath = $basePath . '/' . $componentPath . '.blade.php'; 188 | if (file_exists($fullPath)) { 189 | return $fullPath; 190 | } 191 | 192 | // Try index.blade.php (e.g., form -> form/index.blade.php)... 193 | $indexPath = $basePath . '/' . $componentPath . '/index.blade.php'; 194 | if (file_exists($indexPath)) { 195 | return $indexPath; 196 | } 197 | 198 | // Try same-name file (e.g., card -> card/card.blade.php)... 199 | $lastSegment = basename($componentPath); 200 | $sameNamePath = $basePath . '/' . $componentPath . '/' . $lastSegment . '.blade.php'; 201 | if (file_exists($sameNamePath)) { 202 | return $sameNamePath; 203 | } 204 | } 205 | } 206 | } 207 | 208 | // Fallback to standard components namespace... 209 | try { 210 | return $viewFinder->find("components.{$name}"); 211 | } catch (\Exception $e) { 212 | return ''; 213 | } 214 | } 215 | 216 | protected function freezeObjectProperties(object $object, array $properties) 217 | { 218 | $reflection = new ReflectionClass($object); 219 | 220 | $frozen = []; 221 | 222 | foreach ($properties as $key => $value) { 223 | $name = is_numeric($key) ? $value : $key; 224 | 225 | $property = $reflection->getProperty($name); 226 | 227 | $property->setAccessible(true); 228 | 229 | $frozen[$name] = $property->getValue($object); 230 | 231 | if (! is_numeric($key)) { 232 | $property->setValue($object, $value); 233 | } 234 | } 235 | 236 | return [ 237 | $object, 238 | function () use ($reflection, $object, $frozen) { 239 | foreach ($frozen as $name => $value) { 240 | $property = $reflection->getProperty($name); 241 | $property->setAccessible(true); 242 | $property->setValue($object, $value); 243 | } 244 | }, 245 | ]; 246 | } 247 | } 248 | -------------------------------------------------------------------------------- /AGENTS.md: -------------------------------------------------------------------------------- 1 | # Laravel Blaze - Agent Guidance 2 | 3 | This document provides guidance for AI assistants helping users work with Laravel Blaze, a package that optimizes Blade component rendering performance through compile-time code folding. 4 | 5 | ## Overview 6 | 7 | Laravel Blaze is a performance optimization package that pre-renders static portions of Blade components at compile-time, dramatically reducing runtime overhead. It works by: 8 | 9 | 1. Identifying components marked with the `@blaze` directive in their source 10 | 2. Analyzing component source for runtime dependencies 11 | 3. Pre-rendering eligible components during Blade compilation 12 | 4. Falling back to normal rendering for unsafe components 13 | 14 | ## Core Concepts 15 | 16 | ### The @blaze Directive 17 | 18 | The `@blaze` directive tells Blaze that a component has no runtime dependencies and can be safely optimized. It must be placed at the top of a component file: 19 | 20 | ```blade 21 | @blaze 22 | 23 | @props(['title']) 24 | 25 |

{{ $title }}

26 | ``` 27 | 28 | The `@blaze` directive supports optional parameters to control different optimization strategies: 29 | 30 | ```blade 31 | {{-- All optimizations enabled (default) --}} 32 | @blaze 33 | 34 | {{-- Explicitly enable all optimizations --}} 35 | @blaze(fold: true, memo: true, aware: true) 36 | 37 | {{-- Disable specific optimizations --}} 38 | @blaze(fold: false, memo: true, aware: false) 39 | ``` 40 | 41 | **Parameters:** 42 | - `fold: true/false` - Enable compile-time code folding (default: true) 43 | - `memo: true/false` - Enable runtime memoization (default: true) 44 | - `aware: true/false` - Enable `@aware` directive support (default: true) 45 | 46 | ### Code Folding Process 47 | 48 | When a `@blaze` component is encountered, Blaze: 49 | 1. Replaces dynamic content being passed in via attributes or slots with placeholders 50 | 2. Renders the component with placeholders 51 | 3. Validates that placeholders are preserved 52 | 4. Replaces placeholders with original dynamic content 53 | 5. Outputs the optimized HTML directly into the parent template 54 | 55 | ### Runtime Memoization 56 | 57 | When a component can't be folded (due to dynamic content), Blaze automatically falls back to runtime memoization. This caches the rendered output based on the component name and props, so identical components don't need to be re-rendered. 58 | 59 | ## Helping Users Analyze Components 60 | 61 | When a user asks about adding `@blaze` to a component or wants you to analyze their components, follow this process: 62 | 63 | ### 1. Read and Analyze the Component 64 | 65 | First, examine the component source code for: 66 | - Runtime dependencies (see unsafe patterns below) 67 | - Dynamic content that changes per request 68 | - Dependencies on global state or context 69 | 70 | ### 2. Safe Patterns for @blaze 71 | 72 | Components are safe for `@blaze` when they only: 73 | - Accept props and render them consistently 74 | - Perform simple data formatting (dates, strings, etc.) 75 | - Render slots without modification 76 | 77 | Examples: 78 | ```blade 79 | {{-- UI components --}} 80 | @blaze 81 |
{{ $slot }}
82 | 83 | {{-- Prop-based styling --}} 84 | @blaze 85 | @props(['variant' => 'primary']) 86 | 87 | 88 | {{-- Simple formatting --}} 89 | @blaze 90 | @props(['price']) 91 | ${{ number_format($price, 2) }} 92 | 93 | {{-- Components using @aware --}} 94 | @blaze 95 | @aware(['theme']) 96 | @props(['theme' => 'light']) 97 |
{{ $slot }}
98 | ``` 99 | 100 | ### 3. Unsafe Patterns (Never @blaze) 101 | 102 | **Authentication & Authorization:** 103 | - `@auth`, `@guest`, `@can`, `@cannot` 104 | - `auth()`, `user()` 105 | - Any user-specific content 106 | 107 | **Request Data:** 108 | - `request()` or `Request::` calls 109 | - `old()` for form data 110 | - `route()` with current parameters like `route()->is(...)` 111 | - URL helpers that depend on current request 112 | 113 | **Session & State:** 114 | - `session()` calls 115 | - Flash messages 116 | - `$errors` variable 117 | - CSRF tokens (`@csrf`, `csrf_token()`) 118 | 119 | **Time-Dependent:** 120 | - `now()`, `today()`, `Carbon::now()` 121 | - Any content that changes based on current time 122 | 123 | **Laravel Directives:** 124 | - `@error`, `@enderror` 125 | - `@method` 126 | - Any directive that depends on request context 127 | 128 | **Component Dependencies:** 129 | - Components that inherit parent props (except when using `@aware`) 130 | 131 | **Pagination:** 132 | - `$paginator->links()`, `$paginator->render()` 133 | - Any pagination-related methods or properties 134 | - Components that display pagination controls 135 | - Data tables with pagination 136 | 137 | **Nested Non-Foldable Components:** 138 | - Components that contain other components which use runtime data 139 | - Parent components can't be `@blaze` if any child component is dynamic 140 | - Watch for `` tags inside the component that might be non-foldable 141 | 142 | ### 4. Analysis Process 143 | 144 | When analyzing a component: 145 | 146 | 1. **Scan for unsafe patterns** using the lists above 147 | 2. **Check for child components** - look for any `` tags and verify they are also foldable 148 | 3. **Check for indirect dependencies** - props that might contain dynamic data (like paginator objects) 149 | 4. **Consider context** - how the component is typically used 150 | 5. **Test edge cases** - what happens with different prop values 151 | 152 | #### Special Case: Nested Components 153 | 154 | When a component directly renders other Blade components in its template (not via slots), verify those are also foldable: 155 | 156 | ```blade 157 | {{-- Parent component --}} 158 | @blaze 159 | 160 |
161 | 162 | {{ $slot }} 163 | 164 | 165 |
166 | ``` 167 | 168 | **Key distinction**: 169 | - Components **hardcoded in the template** must be foldable for the parent to be @blaze 170 | - Content **passed through slots** is handled separately and can be dynamic 171 | 172 | ### 5. Making Recommendations 173 | 174 | **For safe components:** 175 | ``` 176 | This component is safe for @blaze because it only renders static HTML and passed props. Add @blaze at the top of the file. 177 | ``` 178 | 179 | **For unsafe components:** 180 | ``` 181 | This component cannot use @blaze because it contains [specific pattern]. The [pattern] changes at runtime and would be frozen at compile-time, causing incorrect behavior. 182 | ``` 183 | 184 | **For borderline cases:** 185 | ``` 186 | This component might be safe for @blaze, but consider if [specific concern]. Test thoroughly after adding @blaze to ensure it behaves correctly across different requests. If folding isn't possible, memoization will still provide performance benefits. 187 | ``` 188 | 189 | ## Common User Requests 190 | 191 | ### "Can I add @blaze to this component?" 192 | 193 | 1. Read the component file 194 | 2. Analyze for unsafe patterns 195 | 3. Provide a clear yes/no with explanation 196 | 4. If no, suggest alternatives or modifications 197 | 198 | ### "Add @blaze to my components" 199 | 200 | 1. Find all component files (`resources/views/components/**/*.blade.php`) 201 | 2. Analyze each component individually 202 | 3. Add `@blaze` only to safe components (include a line break after `@blaze` ) 203 | 4. Report which components were modified and which were skipped with reasons 204 | 205 | ### "Optimize my Blade components" 206 | 207 | 1. Audit existing components for @blaze eligibility 208 | 2. Identify components that could be refactored to be foldable 209 | 3. Suggest architectural improvements for better optimization 210 | 4. Provide before/after examples 211 | 212 | ## Implementation Guidelines 213 | 214 | ### Adding @blaze to Components 215 | 216 | When adding `@blaze` to a component: 217 | 218 | 1. **Always read the component first** to understand its structure 219 | 2. **Add @blaze as the very first line** of the component file 220 | 3. **Preserve existing formatting** and structure 221 | 4. **Don't modify component logic** unless specifically requested 222 | 223 | Example edit: 224 | ```blade 225 | {{-- Before --}} 226 | @props(['title']) 227 | 228 |

{{ $title }}

229 | 230 | {{-- After --}} 231 | @blaze 232 | 233 | @props(['title']) 234 | 235 |

{{ $title }}

236 | ``` 237 | 238 | ### Batch Operations 239 | 240 | When processing multiple components: 241 | 242 | 1. **Process files individually** - don't batch edits 243 | 2. **Report results clearly** - which succeeded, which failed, and why 244 | 3. **Provide summary statistics** - "Added @blaze to 15 of 23 components" 245 | 4. **List problematic components** with specific reasons for skipping 246 | 247 | ### Error Handling 248 | 249 | If Blaze detects unsafe patterns in a `@blaze` component, it will show compilation errors. When helping users: 250 | 251 | 1. **Explain the error** in simple terms 252 | 2. **Show the problematic code** and why it's unsafe 253 | 3. **Suggest solutions** - remove @blaze or refactor the component 254 | 4. **Provide alternatives** if the optimization is important 255 | 256 | ## Testing Recommendations 257 | 258 | After adding `@blaze` to components: 259 | 260 | 1. **Test with different props** to ensure consistent rendering 261 | 2. **Verify in different contexts** - authenticated vs guest users 262 | 3. **Check edge cases** - empty props, unusual values 263 | 4. **Monitor for compilation errors** when views are first accessed 264 | 265 | ## Performance Considerations 266 | 267 | - **Start with simple components** - buttons, cards, badges 268 | - **Focus on frequently used components** for maximum impact 269 | - **Avoid premature optimization** - profile first to identify bottlenecks 270 | - **Monitor compilation time** - too many complex optimizations can slow builds 271 | -------------------------------------------------------------------------------- /src/Folder/Folder.php: -------------------------------------------------------------------------------- 1 | renderBlade = $renderBlade; 25 | $this->renderNodes = $renderNodes; 26 | $this->componentNameToPath = $componentNameToPath; 27 | } 28 | 29 | public function isFoldable(Node $node): bool 30 | { 31 | if (! $node instanceof ComponentNode) { 32 | return false; 33 | } 34 | 35 | try { 36 | $componentPath = ($this->componentNameToPath)($node->name); 37 | 38 | if (empty($componentPath) || !file_exists($componentPath)) { 39 | return false; 40 | } 41 | 42 | $source = file_get_contents($componentPath); 43 | 44 | $directiveParameters = BlazeDirective::getParameters($source); 45 | 46 | if (is_null($directiveParameters)) { 47 | return false; 48 | } 49 | 50 | // Default to true if fold parameter is not specified 51 | return $directiveParameters['fold'] ?? true; 52 | 53 | } catch (\Exception $e) { 54 | return false; 55 | } 56 | } 57 | 58 | public function fold(Node $node): Node 59 | { 60 | if (! $node instanceof ComponentNode) { 61 | return $node; 62 | } 63 | 64 | if (! $this->isFoldable($node)) { 65 | return $node; 66 | } 67 | 68 | /** @var ComponentNode $component */ 69 | $component = $node; 70 | 71 | try { 72 | $componentPath = ($this->componentNameToPath)($component->name); 73 | 74 | if (file_exists($componentPath)) { 75 | $source = file_get_contents($componentPath); 76 | 77 | $this->validateFoldableComponent($source, $componentPath); 78 | 79 | $directiveParameters = BlazeDirective::getParameters($source); 80 | 81 | // Default to true if aware parameter is not specified 82 | if ($directiveParameters['aware'] ?? true) { 83 | $awareAttributes = $this->getAwareDirectiveAttributes($source); 84 | 85 | if (! empty($awareAttributes)) { 86 | $component->mergeAwareAttributes($awareAttributes); 87 | } 88 | } 89 | } 90 | 91 | [$processedNode, $slotPlaceholders, $restore, $attributeNameToPlaceholder, $attributeNameToOriginal, $rawAttributes] = $component->replaceDynamicPortionsWithPlaceholders( 92 | renderNodes: fn (array $nodes) => ($this->renderNodes)($nodes) 93 | ); 94 | 95 | $usageBlade = ($this->renderNodes)([$processedNode]); 96 | 97 | $renderedHtml = ($this->renderBlade)($usageBlade); 98 | 99 | $finalHtml = $restore($renderedHtml); 100 | 101 | $shouldInjectAwareMacros = $this->hasAwareDescendant($component); 102 | 103 | if ($shouldInjectAwareMacros) { 104 | $dataArrayLiteral = $this->buildRuntimeDataArray($attributeNameToOriginal, $rawAttributes); 105 | 106 | if ($dataArrayLiteral !== '[]') { 107 | $finalHtml = 'pushConsumableComponentData(' . $dataArrayLiteral . '); ?>' . $finalHtml . 'popConsumableComponentData(); ?>'; 108 | } 109 | } 110 | 111 | if ($this->containsLeftoverPlaceholders($finalHtml)) { 112 | $summary = $this->summarizeLeftoverPlaceholders($finalHtml); 113 | 114 | throw new LeftoverPlaceholdersException($component->name, $summary, substr($finalHtml, 0, 2000)); 115 | } 116 | 117 | Event::dispatch(new ComponentFolded( 118 | name: $component->name, 119 | path: $componentPath, 120 | filemtime: filemtime($componentPath) 121 | )); 122 | 123 | return new TextNode($finalHtml); 124 | 125 | } catch (InvalidBlazeFoldUsageException $e) { 126 | throw $e; 127 | } catch (\Exception $e) { 128 | if (app('blaze')->isDebugging()) { 129 | throw $e; 130 | } 131 | 132 | return $component; 133 | } 134 | } 135 | 136 | protected function validateFoldableComponent(string $source, string $componentPath): void 137 | { 138 | // Strip out @unblaze blocks before validation since they can contain dynamic content 139 | $sourceWithoutUnblaze = $this->stripUnblazeBlocks($source); 140 | 141 | $problematicPatterns = [ 142 | '@once' => 'forOnce', 143 | '\\$errors' => 'forErrors', 144 | 'session\\(' => 'forSession', 145 | '@error\\(' => 'forError', 146 | '@csrf' => 'forCsrf', 147 | 'auth\\(\\)' => 'forAuth', 148 | 'request\\(\\)' => 'forRequest', 149 | 'old\\(' => 'forOld', 150 | ]; 151 | 152 | foreach ($problematicPatterns as $pattern => $factoryMethod) { 153 | if (preg_match('/' . $pattern . '/', $sourceWithoutUnblaze)) { 154 | throw InvalidBlazeFoldUsageException::{$factoryMethod}($componentPath); 155 | } 156 | } 157 | } 158 | 159 | protected function stripUnblazeBlocks(string $source): string 160 | { 161 | // Remove content between @unblaze and @endunblaze (including the directives themselves) 162 | return preg_replace('/@unblaze.*?@endunblaze/s', '', $source); 163 | } 164 | 165 | protected function buildRuntimeDataArray(array $attributeNameToOriginal, string $rawAttributes): string 166 | { 167 | $pairs = []; 168 | 169 | // Dynamic attributes -> original expressions... 170 | foreach ($attributeNameToOriginal as $name => $original) { 171 | $key = $this->toCamelCase($name); 172 | 173 | if (preg_match('/\{\{\s*\$([a-zA-Z0-9_]+)\s*\}\}/', $original, $m)) { 174 | $pairs[$key] = '$' . $m[1]; 175 | } else { 176 | $pairs[$key] = var_export($original, true); 177 | } 178 | } 179 | 180 | // Static attributes from the original attribute string... 181 | if (! empty($rawAttributes)) { 182 | if (preg_match_all('/\b([a-zA-Z0-9_-]+)="([^"]*)"/', $rawAttributes, $matches, PREG_SET_ORDER)) { 183 | foreach ($matches as $m) { 184 | $name = $m[1]; 185 | $value = $m[2]; 186 | $key = $this->toCamelCase($name); 187 | if (! isset($pairs[$key])) { 188 | $pairs[$key] = var_export($value, true); 189 | } 190 | } 191 | } 192 | } 193 | 194 | if (empty($pairs)) return '[]'; 195 | 196 | $parts = []; 197 | foreach ($pairs as $name => $expr) { 198 | $parts[] = var_export($name, true) . ' => ' . $expr; 199 | } 200 | 201 | return '[' . implode(', ', $parts) . ']'; 202 | } 203 | 204 | protected function getAwareDirectiveAttributes(string $source): array 205 | { 206 | preg_match('/@aware\(\[(.*?)\]\)/s', $source, $matches); 207 | 208 | if (empty($matches[1])) { 209 | return []; 210 | } 211 | 212 | $attributeParser = new AttributeParser(); 213 | 214 | return $attributeParser->parseArrayStringIntoArray($matches[1]); 215 | } 216 | 217 | protected function hasAwareDescendant(Node $node): bool 218 | { 219 | $children = []; 220 | 221 | if ($node instanceof ComponentNode || $node instanceof SlotNode) { 222 | $children = $node->children; 223 | } 224 | 225 | foreach ($children as $child) { 226 | if ($child instanceof ComponentNode) { 227 | $path = ($this->componentNameToPath)($child->name); 228 | 229 | if ($path && file_exists($path)) { 230 | $source = file_get_contents($path); 231 | 232 | if (preg_match('/@aware/', $source)) { 233 | return true; 234 | } 235 | } 236 | 237 | if ($this->hasAwareDescendant($child)) { 238 | return true; 239 | } 240 | } elseif ($child instanceof SlotNode) { 241 | if ($this->hasAwareDescendant($child)) { 242 | return true; 243 | } 244 | } 245 | } 246 | 247 | return false; 248 | } 249 | 250 | protected function toCamelCase(string $name): string 251 | { 252 | $name = str_replace(['-', '_'], ' ', $name); 253 | 254 | $name = ucwords($name); 255 | 256 | $name = str_replace(' ', '', $name); 257 | 258 | return lcfirst($name); 259 | } 260 | 261 | protected function containsLeftoverPlaceholders(string $html): bool 262 | { 263 | return (bool) preg_match('/\b(SLOT_PLACEHOLDER_\d+|ATTR_PLACEHOLDER_\d+|NAMED_SLOT_[A-Za-z0-9_-]+)\b/', $html); 264 | } 265 | 266 | protected function summarizeLeftoverPlaceholders(string $html): string 267 | { 268 | preg_match_all('/\b(SLOT_PLACEHOLDER_\d+|ATTR_PLACEHOLDER_\d+|NAMED_SLOT_[A-Za-z0-9_-]+)\b/', $html, $matches); 269 | 270 | $counts = array_count_values($matches[1] ?? []); 271 | 272 | $parts = []; 273 | 274 | foreach ($counts as $placeholder => $count) { 275 | $parts[] = $placeholder . ' x' . $count; 276 | } 277 | 278 | return implode(', ', $parts); 279 | } 280 | } 281 | -------------------------------------------------------------------------------- /tests/FoldTest.php: -------------------------------------------------------------------------------- 1 | anonymousComponentPath(__DIR__ . '/fixtures/components'); 6 | }); 7 | 8 | function compile(string $input): string { 9 | return app('blaze')->compile($input); 10 | } 11 | 12 | it('simple component', function () { 13 | $input = 'Save'; 14 | $output = ''; 15 | 16 | expect(compile($input))->toBe($output); 17 | }); 18 | 19 | it('strips double quotes from attributes with string literals', function () { 20 | $input = ''; 21 | 22 | expect(compile($input))->not->toContain('src=""'); 23 | expect(compile($input))->not->toContain('alt=""'); 24 | }); 25 | 26 | it('strips double quotes from complex dynamic attributes', function () { 27 | $input = ''; 28 | 29 | expect(compile($input))->toContain('src="{{ $baz->qux }}" alt="{{ $foo->bar }}"'); 30 | }); 31 | 32 | it('with static props', function () { 33 | $input = ''; 34 | $output = '
Success!
'; 35 | 36 | expect(compile($input))->toBe($output); 37 | }); 38 | 39 | it('with static props containing dynamic characters like dollar signs', function () { 40 | $input = ''; 41 | $output = ''; 42 | 43 | expect(compile($input))->toBe($output); 44 | }); 45 | 46 | it('dynamic slot', function () { 47 | $input = '{{ $name }}'; 48 | $output = ''; 49 | 50 | expect(compile($input))->toBe($output); 51 | }); 52 | 53 | it('dynamic attributes', function () { 54 | $input = 'Save'; 55 | $output = ''; 56 | 57 | expect(compile($input))->toBe($output); 58 | }); 59 | 60 | it('dynamic short attributes', function () { 61 | $input = 'Save'; 62 | $output = ''; 63 | 64 | expect(compile($input))->toBe($output); 65 | }); 66 | 67 | it('dynamic echo attributes', function () { 68 | $input = 'Save'; 69 | $output = ''; 70 | 71 | expect(compile($input))->toBe($output); 72 | }); 73 | 74 | it('dynamic slot with unfoldable component', function () { 75 | $input = '{{ $name }}'; 76 | $output = ''; 77 | 78 | expect(compile($input))->toBe($output); 79 | }); 80 | 81 | it('nested components', function () { 82 | $input = <<<'HTML' 83 | 84 | Edit 85 | Delete 86 | 87 | HTML; 88 | 89 | $output = << 91 | 92 | 93 |
94 | HTML; 95 | 96 | expect(compile($input))->toBe($output); 97 | }); 98 | 99 | it('deeply nested components', function () { 100 | $input = <<<'HTML' 101 | 102 | 103 | Save 104 | 105 | 106 | HTML; 107 | 108 | $output = << 110 |
111 | 112 |
113 |
114 | HTML; 115 | 116 | expect(compile($input))->toBe($output); 117 | }); 118 | 119 | it('self-closing component', function () { 120 | $input = ''; 121 | $output = '
Success!
'; 122 | 123 | expect(compile($input))->toBe($output); 124 | }); 125 | 126 | it('component without @blaze is not folded', function () { 127 | $input = 'Save'; 128 | $output = 'Save'; 129 | 130 | expect(compile($input))->toBe($output); 131 | }); 132 | 133 | it('throws exception for invalid foldable usage with $pattern', function (string $pattern, string $expectedPattern) { 134 | $folder = app('blaze')->folder(); 135 | $componentNode = new \Livewire\Blaze\Nodes\ComponentNode("invalid-foldable.{$pattern}", 'x', '', [], false); 136 | 137 | expect(fn() => $folder->fold($componentNode)) 138 | ->toThrow(\Livewire\Blaze\Exceptions\InvalidBlazeFoldUsageException::class); 139 | 140 | try { 141 | $folder->fold($componentNode); 142 | } catch (\Livewire\Blaze\Exceptions\InvalidBlazeFoldUsageException $e) { 143 | expect($e->getMessage())->toContain('Invalid @blaze fold usage'); 144 | expect($e->getComponentPath())->toContain("invalid-foldable/{$pattern}.blade.php"); 145 | expect($e->getProblematicPattern())->toBe($expectedPattern); 146 | } 147 | })->with([ 148 | ['errors', '\\$errors'], 149 | ['session', 'session\\('], 150 | ['error', '@error\\('], 151 | ['csrf', '@csrf'], 152 | ['auth', 'auth\\(\\)'], 153 | ['request', 'request\\(\\)'], 154 | ['old', 'old\\('], 155 | ['once', '@once'], 156 | ]); 157 | 158 | it('named slots', function () { 159 | $input = ' 160 | Modal Title 161 | Footer Content 162 | Main content 163 | '; 164 | 165 | $output = ''; 170 | 171 | expect(compile($input))->toBe($output); 172 | }); 173 | 174 | it('supports folding aware components with single word attributes', function () { 175 | $input = ''; 176 | $output = '
'; 177 | 178 | expect(compile($input))->toBe($output); 179 | }); 180 | 181 | it('supports folding aware components with hyphenated attributes', function () { 182 | $input = ''; 183 | $output = '
'; 184 | 185 | expect(compile($input))->toBe($output); 186 | }); 187 | 188 | it('supports folding aware components with two wrapping components both with the same prop the closest one wins', function () { 189 | $input = ''; 190 | // The foldable-item should render the `secondary` variant because it is the closest one to the foldable-item... 191 | $output = '
'; 192 | 193 | expect(compile($input))->toBe($output); 194 | }); 195 | 196 | it('supports aware on unfoldable components from folded parent with single word attributes', function () { 197 | $input = ''; 198 | 199 | $output = '
'; 200 | 201 | $compiled = compile($input); 202 | $rendered = \Illuminate\Support\Facades\Blade::render($compiled); 203 | 204 | expect($rendered)->toBe($output); 205 | }); 206 | 207 | it('supports aware on unfoldable components from folded parent with hyphenated attributes', function () { 208 | $input = ''; 209 | 210 | $output = '
'; 211 | 212 | $compiled = compile($input); 213 | $rendered = \Illuminate\Support\Facades\Blade::render($compiled); 214 | 215 | expect($rendered)->toBe($output); 216 | }); 217 | 218 | it('supports aware on unfoldable components from folded parent with dynamic attributes', function () { 219 | $input = ' '; 220 | 221 | $output = '
'; 222 | 223 | $compiled = compile($input); 224 | $rendered = \Illuminate\Support\Facades\Blade::render($compiled); 225 | 226 | expect($rendered)->toBe($output); 227 | }); 228 | 229 | it('supports verbatim blocks', function () { 230 | $input = <<<'BLADE' 231 | @verbatim 232 | 233 | Save 234 | 235 | @endverbatim 236 | BLADE; 237 | 238 | $output = <<<'BLADE' 239 | 240 | Save 241 | 242 | 243 | BLADE; 244 | 245 | $rendered = \Illuminate\Support\Facades\Blade::render($input); 246 | 247 | expect($rendered)->toBe($output); 248 | }); 249 | 250 | it('can fold static props that get formatted', function () { 251 | $input = ''; 252 | $output = '
Date is: Fri, Jul 11
'; 253 | 254 | expect(compile($input))->toBe($output); 255 | }); 256 | 257 | it('cant fold dynamic props that get formatted', function () { 258 | $input = ' '; 259 | $output = ' $date]); ?>'; 260 | 261 | expect(compile($input))->toBe($output); 262 | }); 263 | }); 264 | -------------------------------------------------------------------------------- /src/Nodes/ComponentNode.php: -------------------------------------------------------------------------------- 1 | parentsAttributes = $parentsAttributes; 28 | } 29 | 30 | public function toArray(): array 31 | { 32 | $array = [ 33 | 'type' => $this->getType(), 34 | 'name' => $this->name, 35 | 'prefix' => $this->prefix, 36 | 'attributes' => $this->attributes, 37 | 'children' => array_map(fn($child) => $child instanceof Node ? $child->toArray() : $child, $this->children), 38 | 'self_closing' => $this->selfClosing, 39 | 'parents_attributes' => $this->parentsAttributes, 40 | ]; 41 | 42 | return $array; 43 | } 44 | 45 | public function render(): string 46 | { 47 | $name = $this->stripNamespaceFromName($this->name, $this->prefix); 48 | 49 | $output = "<{$this->prefix}{$name}"; 50 | if (!empty($this->attributes)) { 51 | $output .= " {$this->attributes}"; 52 | } 53 | if ($this->selfClosing) { 54 | return $output . ' />'; 55 | } 56 | $output .= '>'; 57 | foreach ($this->children as $child) { 58 | $output .= $child instanceof Node ? $child->render() : (string) $child; 59 | } 60 | $output .= "prefix}{$name}>"; 61 | return $output; 62 | } 63 | 64 | public function getAttributesAsRuntimeArrayString(): string 65 | { 66 | $attributeParser = new AttributeParser(); 67 | 68 | $attributesArray = $attributeParser->parseAttributeStringToArray($this->attributes); 69 | 70 | return $attributeParser->parseAttributesArrayToRuntimeArrayString($attributesArray); 71 | } 72 | 73 | public function replaceDynamicPortionsWithPlaceholders(callable $renderNodes): array 74 | { 75 | $attributePlaceholders = []; 76 | $attributeNameToPlaceholder = []; 77 | $processedAttributes = (new AttributeParser())->parseAndReplaceDynamics( 78 | $this->attributes, 79 | $attributePlaceholders, 80 | $attributeNameToPlaceholder 81 | ); 82 | 83 | // Map attribute name => original dynamic content (if dynamic) 84 | $attributeNameToOriginal = []; 85 | foreach ($attributeNameToPlaceholder as $name => $placeholder) { 86 | if (isset($attributePlaceholders[$placeholder])) { 87 | $attributeNameToOriginal[$name] = $attributePlaceholders[$placeholder]; 88 | } 89 | } 90 | 91 | $processedNode = new self( 92 | name: $this->name, 93 | prefix: $this->prefix, 94 | attributes: $processedAttributes, 95 | children: [], 96 | selfClosing: $this->selfClosing, 97 | ); 98 | 99 | $slotPlaceholders = []; 100 | $defaultSlotChildren = []; 101 | $namedSlotNames = []; 102 | 103 | foreach ($this->children as $child) { 104 | if ($child instanceof SlotNode) { 105 | $slotName = $child->name; 106 | if (!empty($slotName) && $slotName !== 'slot') { 107 | $slotContent = $renderNodes($child->children); 108 | $slotPlaceholders['NAMED_SLOT_' . $slotName] = $slotContent; 109 | $namedSlotNames[] = $slotName; 110 | } else { 111 | foreach ($child->children as $grandChild) { 112 | $defaultSlotChildren[] = $grandChild; 113 | } 114 | } 115 | } else { 116 | $defaultSlotChildren[] = $child; 117 | } 118 | } 119 | 120 | // Emit real placeholder nodes for named slots and separate with zero-output PHP... 121 | $count = count($namedSlotNames); 122 | foreach ($namedSlotNames as $index => $name) { 123 | if ($index > 0) { 124 | $processedNode->children[] = new TextNode(''); 125 | } 126 | $processedNode->children[] = new SlotNode( 127 | name: $name, 128 | attributes: '', 129 | slotStyle: 'standard', 130 | children: [new TextNode('NAMED_SLOT_' . $name)], 131 | prefix: 'x-slot', 132 | ); 133 | } 134 | 135 | $defaultPlaceholder = null; 136 | if (!empty($defaultSlotChildren)) { 137 | if ($count > 0) { 138 | // Separate last named slot from default content with zero-output PHP... 139 | $processedNode->children[] = new TextNode(''); 140 | } 141 | $defaultPlaceholder = 'SLOT_PLACEHOLDER_' . count($slotPlaceholders); 142 | $renderedDefault = $renderNodes($defaultSlotChildren); 143 | $slotPlaceholders[$defaultPlaceholder] = ($count > 0) ? trim($renderedDefault) : $renderedDefault; 144 | $processedNode->children[] = new TextNode($defaultPlaceholder); 145 | } else { 146 | $processedNode->children[] = new TextNode(''); 147 | } 148 | 149 | $restore = function (string $renderedHtml) use ($slotPlaceholders, $attributePlaceholders, $defaultPlaceholder): string { 150 | // Replace slot placeholders first... 151 | foreach ($slotPlaceholders as $placeholder => $content) { 152 | if ($placeholder === $defaultPlaceholder) { 153 | // Trim whitespace immediately around the default placeholder position... 154 | $pattern = '/\s*' . preg_quote($placeholder, '/') . '\s*/'; 155 | $renderedHtml = preg_replace($pattern, $content, $renderedHtml); 156 | } else { 157 | $renderedHtml = str_replace($placeholder, $content, $renderedHtml); 158 | } 159 | } 160 | // Restore attribute placeholders... 161 | foreach ($attributePlaceholders as $placeholder => $original) { 162 | $renderedHtml = str_replace($placeholder, $original, $renderedHtml); 163 | } 164 | return $renderedHtml; 165 | }; 166 | 167 | return [$processedNode, $slotPlaceholders, $restore, $attributeNameToPlaceholder, $attributeNameToOriginal, $this->attributes]; 168 | } 169 | 170 | public function mergeAwareAttributes(array $awareAttributes): void 171 | { 172 | $attributeParser = new AttributeParser(); 173 | 174 | // Attributes are a string of attributes in the format: 175 | // `name1="value1" name2="value2" name3="value3"` 176 | // So we need to convert that attributes string to an array of attributes with the format: 177 | // [ 178 | // 'name' => [ 179 | // 'isDynamic' => true, 180 | // 'value' => '$name', 181 | // 'original' => ':name="$name"', 182 | // ], 183 | // ] 184 | $attributes = $attributeParser->parseAttributeStringToArray($this->attributes); 185 | 186 | $parentsAttributes = []; 187 | 188 | // Parents attributes are an array of attributes strings in the same format 189 | // as above so we also need to convert them to an array of attributes... 190 | foreach ($this->parentsAttributes as $parentAttributes) { 191 | $parentsAttributes[] = $attributeParser->parseAttributeStringToArray($parentAttributes); 192 | } 193 | 194 | // Now we can take the aware attributes and merge them with the components attributes... 195 | foreach ($awareAttributes as $key => $value) { 196 | // As `$awareAttributes` is an array of attributes, which can either have just 197 | // a value, which is the attribute name, or a key-value pair, which is the 198 | // attribute name and a default value... 199 | if (is_int($key)) { 200 | $attributeName = $value; 201 | $attributeValue = null; 202 | $defaultValue = null; 203 | } else { 204 | $attributeName = $key; 205 | $attributeValue = $value; 206 | $defaultValue = [ 207 | 'isDynamic' => false, 208 | 'value' => $attributeValue, 209 | 'original' => $attributeName . '="' . $attributeValue . '"', 210 | ]; 211 | } 212 | 213 | if (isset($attributes[$attributeName])) { 214 | continue; 215 | } 216 | 217 | // Loop through the parents attributes in reverse order so that the last parent 218 | // attribute that matches the attribute name is used... 219 | foreach (array_reverse($parentsAttributes) as $parsedParentAttributes) { 220 | // If an attribute is found, then use it and stop searching... 221 | if (isset($parsedParentAttributes[$attributeName])) { 222 | $attributes[$attributeName] = $parsedParentAttributes[$attributeName]; 223 | break; 224 | } 225 | } 226 | 227 | // If the attribute is not set then fall back to using the aware value. 228 | // We need to add it in the same format as the other attributes... 229 | if (! isset($attributes[$attributeName]) && $defaultValue !== null) { 230 | $attributes[$attributeName] = $defaultValue; 231 | } 232 | } 233 | 234 | // Convert the parsed attributes back to a string with the original format: 235 | // `name1="value1" name2="value2" name3="value3"` 236 | $this->attributes = $attributeParser->parseAttributesArrayToPropString($attributes); 237 | } 238 | 239 | protected function stripNamespaceFromName(string $name, string $prefix): string 240 | { 241 | $prefixes = [ 242 | 'flux:' => [ 'namespace' => 'flux::' ], 243 | 'x:' => [ 'namespace' => '' ], 244 | 'x-' => [ 'namespace' => '' ], 245 | ]; 246 | if (isset($prefixes[$prefix])) { 247 | $namespace = $prefixes[$prefix]['namespace']; 248 | if (!empty($namespace) && str_starts_with($name, $namespace)) { 249 | return substr($name, strlen($namespace)); 250 | } 251 | } 252 | return $name; 253 | } 254 | } -------------------------------------------------------------------------------- /tests/UnblazeTest.php: -------------------------------------------------------------------------------- 1 | anonymousComponentNamespace('', 'x'); 9 | app('blade.compiler')->anonymousComponentPath(__DIR__ . '/fixtures/components'); 10 | 11 | // Add view path for our test pages 12 | View::addLocation(__DIR__ . '/fixtures/pages'); 13 | }); 14 | 15 | it('folds component but preserves unblaze block', function () { 16 | $input = ''; 17 | 18 | $compiled = app('blaze')->compile($input); 19 | 20 | // The component should be folded 21 | expect($compiled)->toContain('
'); 22 | expect($compiled)->toContain('

Static Header

'); 23 | expect($compiled)->toContain('
Static Footer
'); 24 | 25 | // The unblaze block should be preserved as dynamic content 26 | expect($compiled)->toContain('{{ $dynamicValue }}'); 27 | expect($compiled)->not->toContain('compile($input); 34 | 35 | // Static parts should be folded 36 | expect($compiled)->toContain('Static Header'); 37 | expect($compiled)->toContain('Static Footer'); 38 | 39 | // Dynamic parts inside @unblaze should remain dynamic 40 | expect($compiled)->toContain('$dynamicValue'); 41 | }); 42 | 43 | it('handles unblaze with scope parameter', function () { 44 | $input = ' '; 45 | 46 | $compiled = app('blaze')->compile($input); 47 | 48 | // The component should be folded 49 | expect($compiled)->toContain('
'); 50 | expect($compiled)->toContain('

Title

'); 51 | expect($compiled)->toContain('

Static paragraph

'); 52 | 53 | // The scope should be captured and made available 54 | expect($compiled)->toContain('$scope'); 55 | }); 56 | 57 | it('encodes scope into compiled view for runtime access', function () { 58 | $input = ' '; 59 | 60 | $compiled = app('blaze')->compile($input); 61 | 62 | // Should contain PHP code to set up the scope 63 | expect($compiled)->toMatch('/\$scope\s*=\s*array\s*\(/'); 64 | 65 | // The dynamic content should reference $scope 66 | expect($compiled)->toContain('$scope[\'message\']'); 67 | }); 68 | 69 | it('renders unblaze component correctly at runtime', function () { 70 | $template = ' '; 71 | 72 | $rendered = \Illuminate\Support\Facades\Blade::render($template); 73 | 74 | // Static content should be present 75 | expect($rendered)->toContain('
'); 76 | expect($rendered)->toContain('

Title

'); 77 | expect($rendered)->toContain('

Static paragraph

'); 78 | 79 | // Dynamic content should be rendered with the scope value 80 | // Note: The actual rendering of scope variables happens at runtime 81 | expect($rendered)->toContain('class="dynamic"'); 82 | }); 83 | 84 | it('allows punching a hole in static component for dynamic section', function () { 85 | $input = <<<'BLADE' 86 | 87 | Static content here 88 | @unblaze 89 | {{ $dynamicValue }} 90 | @endunblaze 91 | More static content 92 | 93 | BLADE; 94 | 95 | $compiled = app('blaze')->compile($input); 96 | 97 | // Card should be folded 98 | expect($compiled)->toContain('
'); 99 | expect($compiled)->toContain('Static content here'); 100 | expect($compiled)->toContain('More static content'); 101 | 102 | // But dynamic part should be preserved 103 | expect($compiled)->toContain('{{ $dynamicValue }}'); 104 | expect($compiled)->not->toContain(''); 105 | }); 106 | 107 | it('supports multiple unblaze blocks in same component', function () { 108 | $template = <<<'BLADE' 109 | @blaze 110 |
111 |

Static 1

112 | @unblaze 113 | {{ $dynamic1 }} 114 | @endunblaze 115 |

Static 2

116 | @unblaze 117 | {{ $dynamic2 }} 118 | @endunblaze 119 |

Static 3

120 |
121 | BLADE; 122 | 123 | $compiled = app('blaze')->compile($template); 124 | 125 | // All static parts should be folded 126 | expect($compiled)->toContain('

Static 1

'); 127 | expect($compiled)->toContain('

Static 2

'); 128 | expect($compiled)->toContain('

Static 3

'); 129 | 130 | // Both dynamic parts should be preserved 131 | expect($compiled)->toContain('{{ $dynamic1 }}'); 132 | expect($compiled)->toContain('{{ $dynamic2 }}'); 133 | }); 134 | 135 | it('handles nested components with unblaze', function () { 136 | $input = <<<'BLADE' 137 | 138 | Static Button 139 | @unblaze 140 | {{ $dynamicLabel }} 141 | @endunblaze 142 | 143 | BLADE; 144 | 145 | $compiled = app('blaze')->compile($input); 146 | 147 | // Outer card and static button should be folded 148 | expect($compiled)->toContain('
'); 149 | expect($compiled)->toContain('Static Button'); 150 | 151 | // Dynamic button inside unblaze should be preserved 152 | expect($compiled)->toContain('{{ $dynamicLabel }}'); 153 | }); 154 | 155 | it('static folded content with random strings stays the same between renders', function () { 156 | // First render 157 | $render1 = \Illuminate\Support\Facades\Blade::render(''); 158 | 159 | // Second render 160 | $render2 = \Illuminate\Support\Facades\Blade::render(''); 161 | 162 | // The random string should be the same because it was folded at compile time 163 | expect($render1)->toBe($render2); 164 | 165 | // Verify it contains the static structure 166 | expect($render1)->toContain('class="static-component"'); 167 | expect($render1)->toContain('This should be folded and not change between renders'); 168 | }); 169 | 170 | it('unblazed dynamic content changes between renders while static parts stay the same', function () { 171 | // First render with a value 172 | $render1 = \Illuminate\Support\Facades\Blade::render('', ['dynamicValue' => 'first-value']); 173 | 174 | // Second render with a different value 175 | $render2 = \Illuminate\Support\Facades\Blade::render('', ['dynamicValue' => 'second-value']); 176 | 177 | // Extract the static parts (header and footer with random strings) 178 | preg_match('/

Static Random: (.+?)<\/h1>/', $render1, $matches1); 179 | preg_match('/

Static Random: (.+?)<\/h1>/', $render2, $matches2); 180 | $staticRandom1 = $matches1[1] ?? ''; 181 | $staticRandom2 = $matches2[1] ?? ''; 182 | 183 | // The static random strings should be IDENTICAL (folded at compile time) 184 | expect($staticRandom1)->toBe($staticRandom2); 185 | expect($staticRandom1)->not->toBeEmpty(); 186 | 187 | // But the dynamic parts should be DIFFERENT 188 | expect($render1)->toContain('Dynamic value: first-value'); 189 | expect($render2)->toContain('Dynamic value: second-value'); 190 | expect($render1)->not->toContain('second-value'); 191 | expect($render2)->not->toContain('first-value'); 192 | }); 193 | 194 | it('multiple renders of unblaze component proves folding optimization', function () { 195 | // Render the same template multiple times with different dynamic values 196 | $renders = []; 197 | foreach (['one', 'two', 'three'] as $value) { 198 | $renders[] = \Illuminate\Support\Facades\Blade::render( 199 | '', 200 | ['dynamicValue' => $value] 201 | ); 202 | } 203 | 204 | // Extract the static footer random string from each render 205 | $staticFooters = []; 206 | foreach ($renders as $render) { 207 | preg_match('/