';
25 |
26 | // This should complete without hanging or crashing
27 | $rendered = \Illuminate\Support\Facades\Blade::render($template);
28 |
29 | expect($rendered)->toContain('
');
30 | expect($rendered)->toContain('
');
31 | expect($rendered)->toContain('Nested alert!');
32 | expect($rendered)->not->toContain('
');
33 | expect($rendered)->not->toContain('
39 | Button 1
40 |
41 | Button 2
42 | ';
43 |
44 | // This should complete without hanging or crashing
45 | $rendered = \Illuminate\Support\Facades\Blade::render($template);
46 |
47 | expect($rendered)->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('
60 |
61 | Outer Card
62 |
63 | Inner Card
64 | Nested Button
65 |
66 |
67 | Outer Button
68 |
69 |
';
70 |
71 | // This should complete without hanging or crashing
72 | $rendered = \Illuminate\Support\Facades\Blade::render($template);
73 |
74 | expect($rendered)->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('
93 | Save Changes
94 | ';
95 |
96 | // This should complete without hanging or crashing
97 | $rendered = \Illuminate\Support\Facades\Blade::render($template);
98 |
99 | expect($rendered)->toContain('
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 | Save
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 = '
166 |
167 |
Main content
168 |
169 |
';
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 .= "{$this->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('
');
24 |
25 | // The unblaze block should be preserved as dynamic content
26 | expect($compiled)->toContain('{{ $dynamicValue }}');
27 | expect($compiled)->not->toContain('
';
32 |
33 | $compiled = app('blaze')->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('/