Skip to content

Commit 413e0cf

Browse files
committed
Properly implement nested fragments
1 parent ae4cec4 commit 413e0cf

21 files changed

+455
-14
lines changed

CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,18 @@
22

33
All notable changes to `laravel-htmx` will be documented in this file.
44

5+
## 0.4.0 - 2023-08-02
6+
7+
### What's Changed
8+
9+
- Added support for nested fragments (requires `ext-mbstring`)
10+
11+
## 0.3.0 - 2023-03-25
12+
13+
### What's Changed
14+
15+
- Added support for Laravel 10
16+
517
## 0.2.1 - 2022-11-19
618

719
### What's Changed

composer.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
}
1717
],
1818
"require": {
19+
"ext-mbstring": "*",
1920
"php": "^8.0|^8.1|^8.2",
2021
"illuminate/contracts": "^8.80|^9.0|^10.0"
2122
},

src/LaravelHtmxServiceProvider.php

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111

1212
class LaravelHtmxServiceProvider extends ServiceProvider
1313
{
14-
public function boot()
14+
public function boot(): void
1515
{
1616
if ($this->app->runningInConsole()) {
1717
$this->bootForConsole();
@@ -37,14 +37,14 @@ public function boot()
3737
*
3838
* @return void
3939
*/
40-
protected function bootForConsole()
40+
protected function bootForConsole(): void
4141
{
4242
$this->publishes([
4343
__DIR__.'/../config/laravel-htmx.php' => config_path('laravel-htmx.php'),
4444
], 'config');
4545
}
4646

47-
public function register()
47+
public function register(): void
4848
{
4949
$this->mergeConfigFrom(
5050
__DIR__.'/../config/laravel-htmx.php',

src/View/BladeFragment.php

Lines changed: 57 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
<?php
22

3+
declare(strict_types=1);
4+
35
namespace Mauricius\LaravelHtmx\View;
46

57
use Illuminate\Support\Facades\Blade;
@@ -8,18 +10,69 @@
810

911
class BladeFragment
1012
{
13+
public const OPEN = 'fragment';
14+
public const CLOSE = 'endfragment';
15+
1116
public static function render(string $view, string $fragment, array $data = []): string
1217
{
1318
$path = View::make($view, $data)->getPath();
1419

1520
$content = File::get($path);
1621

17-
$re = sprintf('/(?<!@)@fragment[ \t]*\([\'"]{1}%s[\'"]{1}\)(.*?)@endfragment/s', $fragment);
22+
$output = self::captureFragmentFromContent($fragment, $path, $content);
23+
24+
return Blade::render($output, $data);
25+
}
26+
27+
private static function captureFragmentFromContent(string $fragment, string $path, string $content): string
28+
{
29+
$parser = new BladeFragmentParser(self::OPEN, self::CLOSE);
30+
31+
$nodes = $parser->parse($content);
32+
33+
$node = array_filter($nodes, function (OpenFragmentElement|CloseFragmentElement $node) use ($fragment) {
34+
return $node instanceof OpenFragmentElement && $node->name === $fragment;
35+
});
36+
37+
throw_if(empty($node), "No fragment called \"$fragment\" exists in \"$path\"");
38+
39+
throw_if(count($node) > 1, "Multiple fragments called \"$fragment\" exists in \"$path\"");
40+
41+
$nestedOccurrences = 0;
42+
43+
$openElement = null;
44+
$closeElement = null;
45+
46+
foreach ($nodes as $node) {
47+
if ($openElement === null && $node instanceof OpenFragmentElement) {
48+
if($node->name === $fragment) {
49+
$openElement = $node;
50+
51+
continue;
52+
}
53+
}
54+
55+
if ($openElement !== null && $node instanceof OpenFragmentElement) {
56+
$nestedOccurrences++;
57+
58+
continue;
59+
}
1860

19-
preg_match($re, $content, $matches);
61+
if ($openElement !== null && $node instanceof CloseFragmentElement) {
62+
if ($nestedOccurrences === 0) {
63+
$closeElement = $node;
2064

21-
throw_if(empty($matches), "No fragment called \"$fragment\" exists in \"$path\"");
65+
break;
66+
} else {
67+
$nestedOccurrences--;
68+
}
69+
}
70+
}
2271

23-
return Blade::render($matches[1], $data);
72+
return mb_substr(
73+
$content,
74+
$openElement->endOffset,
75+
$closeElement->startOffset - $openElement->endOffset
76+
);
2477
}
2578
}

src/View/BladeFragmentParser.php

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Mauricius\LaravelHtmx\View;
6+
7+
class BladeFragmentParser
8+
{
9+
public function __construct(private string $openDirective, private string $closeDirective)
10+
{
11+
}
12+
13+
/**
14+
* @param string $content
15+
* @return CloseFragmentElement[]|OpenFragmentElement[]
16+
*/
17+
public function parse(string $content): array
18+
{
19+
$content = $this->normalizeLineEndings($content);
20+
21+
return $this->prepareNodeList($content);
22+
}
23+
24+
/**
25+
* @param string $content
26+
* @return array<OpenFragmentElement|CloseFragmentElement>
27+
*/
28+
private function prepareNodeList(string $content): array
29+
{
30+
$re = sprintf('/(?<!@)@%s[ \t]*\([\'"](.+?)[\'"]\)|@%s/', $this->openDirective, $this->closeDirective);
31+
32+
preg_match_all($re, $content, $matches, PREG_SET_ORDER|PREG_OFFSET_CAPTURE);
33+
34+
if (! is_array($matches) || count($matches) < 2) {
35+
return [];
36+
}
37+
38+
$lastOffset = 0;
39+
40+
/** @var array $nodes */
41+
$nodes = array_map(function (array $match) use ($content, &$lastOffset) {
42+
// Convert regex offsets to multibyte offsets.
43+
$offset = $match[0][1];
44+
45+
if ($offset !== 0) {
46+
$offset = mb_strpos($content, $match[0][0], $lastOffset + 1);
47+
}
48+
49+
if ($offset === false) {
50+
$offset = $match[0][1];
51+
}
52+
53+
$lastOffset = $offset + 1;
54+
55+
if (str_starts_with($match[0][0], sprintf('@%s', $this->openDirective))) {
56+
$openElement = new OpenFragmentElement();
57+
$openElement->name = $match[1][0];
58+
$openElement->startOffset = $offset;
59+
$openElement->endOffset = $offset + mb_strlen($match[0][0]);
60+
61+
return $openElement;
62+
}
63+
64+
if (str_starts_with($match[0][0], sprintf('@%s', $this->closeDirective))) {
65+
$closeElement = new CloseFragmentElement();
66+
$closeElement->startOffset = $offset;
67+
$closeElement->endOffset = $offset + mb_strlen($match[0][0]);
68+
69+
return $closeElement;
70+
}
71+
72+
return null;
73+
}, $matches);
74+
75+
return array_filter($nodes);
76+
}
77+
78+
private function normalizeLineEndings(string $content): string
79+
{
80+
return str_replace(['\r\n', '\r'], '\n', $content);
81+
}
82+
}

src/View/CloseFragmentElement.php

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Mauricius\LaravelHtmx\View;
6+
7+
class CloseFragmentElement extends FragmentElement
8+
{
9+
10+
}

src/View/FragmentElement.php

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Mauricius\LaravelHtmx\View;
6+
7+
abstract class FragmentElement
8+
{
9+
public int $startOffset = 0;
10+
11+
public int $endOffset = 0;
12+
}

src/View/OpenFragmentElement.php

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Mauricius\LaravelHtmx\View;
6+
7+
class OpenFragmentElement extends FragmentElement
8+
{
9+
public string $name = '';
10+
}

tests/FragmentBladeDirectiveTest.php

Lines changed: 54 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,30 @@ public function the_view_still_renders_correctly_if_it_contains_fragments()
2121
$this->assertMatchesSnapshot($renderedView);
2222
}
2323

24+
/** @test */
25+
public function it_throws_an_exception_if_the_specified_fragment_does_not_exists_in_the_view()
26+
{
27+
$fragment = 'missing';
28+
$view = 'basic';
29+
30+
$this->expectException(RuntimeException::class);
31+
$this->expectExceptionMessageMatches("/No fragment called \"$fragment\" exists in \".*\/tests\/views\/$view\.blade\.php\"/m");
32+
33+
view()->renderFragment($view, $fragment);
34+
}
35+
36+
/** @test */
37+
public function it_throws_an_exception_if_the_specified_fragment_exists_multiple_times_in_the_view()
38+
{
39+
$fragment = 'duplicate';
40+
$view = 'duplicate';
41+
42+
$this->expectException(RuntimeException::class);
43+
$this->expectExceptionMessageMatches("/Multiple fragments called \"$fragment\" exists in \".*\/tests\/views\/$view\.blade\.php\"/m");
44+
45+
view()->renderFragment($view, $fragment);
46+
}
47+
2448
/** @test */
2549
public function the_render_fragment_view_macro_can_render_a_single_fragment_whose_name_is_enclosed_in_double_quotes()
2650
{
@@ -42,14 +66,38 @@ public function the_render_fragment_view_macro_can_render_a_single_fragment_whos
4266
}
4367

4468
/** @test */
45-
public function it_throws_an_exception_if_the_specified_fragment_does_not_exists()
69+
public function the_render_fragment_view_macro_can_render_a_single_fragment_defined_inline()
4670
{
47-
$fragment = 'missing';
48-
$view = 'basic';
71+
$message = 'htmx';
4972

50-
$this->expectException(RuntimeException::class);
51-
$this->expectExceptionMessageMatches("/No fragment called \"$fragment\" exists in \".*\/tests\/views\/$view\.blade\.php\"/m");
73+
$renderedView = view()->renderFragment('inline', 'inline', compact('message'));
5274

53-
view()->renderFragment($view, $fragment);
75+
$this->assertMatchesSnapshot($renderedView);
76+
}
77+
78+
/** @test */
79+
public function the_render_fragment_view_macro_can_render_a_single_fragment_even_if_it_is_nested_in_other_fragments()
80+
{
81+
$renderedView = view()->renderFragment('nested', 'inner');
82+
83+
$this->assertMatchesSnapshot($renderedView);
84+
}
85+
86+
/** @test */
87+
public function the_render_fragment_view_macro_can_render_a_single_fragment_even_if_it_is_not_aligned_with_the_closing_fragment()
88+
{
89+
$renderedView = view()->renderFragment('misaligned', 'inner');
90+
91+
$this->assertMatchesSnapshot($renderedView);
92+
}
93+
94+
/** @test */
95+
public function the_render_fragment_view_macro_can_render_a_single_fragment_even_if_it_it_contains_multibyte_characters()
96+
{
97+
$message = 'htmx';
98+
99+
$renderedView = view()->renderFragment('multibyte', 'fünf', compact('message'));
100+
101+
$this->assertMatchesSnapshot($renderedView);
54102
}
55103
}

tests/TestCase.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ public function setUp(): void
1616
{
1717
parent::setUp();
1818

19+
// Testbench seems to forget this hint from time to time
20+
View::addNamespace('__components', storage_path('framework/views'));
21+
1922
View::addLocation(__DIR__.'/views');
2023
}
2124

0 commit comments

Comments
 (0)