Skip to content

Commit

Permalink
Properly implement nested fragments
Browse files Browse the repository at this point in the history
  • Loading branch information
mauricius committed Aug 2, 2023
1 parent ae4cec4 commit 413e0cf
Show file tree
Hide file tree
Showing 21 changed files with 455 additions and 14 deletions.
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,18 @@

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

## 0.4.0 - 2023-08-02

### What's Changed

- Added support for nested fragments (requires `ext-mbstring`)

## 0.3.0 - 2023-03-25

### What's Changed

- Added support for Laravel 10

## 0.2.1 - 2022-11-19

### What's Changed
Expand Down
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
}
],
"require": {
"ext-mbstring": "*",
"php": "^8.0|^8.1|^8.2",
"illuminate/contracts": "^8.80|^9.0|^10.0"
},
Expand Down
6 changes: 3 additions & 3 deletions src/LaravelHtmxServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@

class LaravelHtmxServiceProvider extends ServiceProvider
{
public function boot()
public function boot(): void
{
if ($this->app->runningInConsole()) {
$this->bootForConsole();
Expand All @@ -37,14 +37,14 @@ public function boot()
*
* @return void
*/
protected function bootForConsole()
protected function bootForConsole(): void
{
$this->publishes([
__DIR__.'/../config/laravel-htmx.php' => config_path('laravel-htmx.php'),
], 'config');
}

public function register()
public function register(): void
{
$this->mergeConfigFrom(
__DIR__.'/../config/laravel-htmx.php',
Expand Down
61 changes: 57 additions & 4 deletions src/View/BladeFragment.php
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
<?php

declare(strict_types=1);

namespace Mauricius\LaravelHtmx\View;

use Illuminate\Support\Facades\Blade;
Expand All @@ -8,18 +10,69 @@

class BladeFragment
{
public const OPEN = 'fragment';
public const CLOSE = 'endfragment';

public static function render(string $view, string $fragment, array $data = []): string
{
$path = View::make($view, $data)->getPath();

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

$re = sprintf('/(?<!@)@fragment[ \t]*\([\'"]{1}%s[\'"]{1}\)(.*?)@endfragment/s', $fragment);
$output = self::captureFragmentFromContent($fragment, $path, $content);

return Blade::render($output, $data);
}

private static function captureFragmentFromContent(string $fragment, string $path, string $content): string
{
$parser = new BladeFragmentParser(self::OPEN, self::CLOSE);

$nodes = $parser->parse($content);

$node = array_filter($nodes, function (OpenFragmentElement|CloseFragmentElement $node) use ($fragment) {
return $node instanceof OpenFragmentElement && $node->name === $fragment;
});

throw_if(empty($node), "No fragment called \"$fragment\" exists in \"$path\"");

throw_if(count($node) > 1, "Multiple fragments called \"$fragment\" exists in \"$path\"");

$nestedOccurrences = 0;

$openElement = null;
$closeElement = null;

foreach ($nodes as $node) {
if ($openElement === null && $node instanceof OpenFragmentElement) {
if($node->name === $fragment) {
$openElement = $node;

continue;
}
}

if ($openElement !== null && $node instanceof OpenFragmentElement) {
$nestedOccurrences++;

continue;
}

preg_match($re, $content, $matches);
if ($openElement !== null && $node instanceof CloseFragmentElement) {
if ($nestedOccurrences === 0) {
$closeElement = $node;

throw_if(empty($matches), "No fragment called \"$fragment\" exists in \"$path\"");
break;
} else {
$nestedOccurrences--;
}
}
}

return Blade::render($matches[1], $data);
return mb_substr(
$content,
$openElement->endOffset,
$closeElement->startOffset - $openElement->endOffset
);
}
}
82 changes: 82 additions & 0 deletions src/View/BladeFragmentParser.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
<?php

declare(strict_types=1);

namespace Mauricius\LaravelHtmx\View;

class BladeFragmentParser
{
public function __construct(private string $openDirective, private string $closeDirective)
{
}

/**
* @param string $content
* @return CloseFragmentElement[]|OpenFragmentElement[]
*/
public function parse(string $content): array
{
$content = $this->normalizeLineEndings($content);

return $this->prepareNodeList($content);
}

/**
* @param string $content
* @return array<OpenFragmentElement|CloseFragmentElement>
*/
private function prepareNodeList(string $content): array
{
$re = sprintf('/(?<!@)@%s[ \t]*\([\'"](.+?)[\'"]\)|@%s/', $this->openDirective, $this->closeDirective);

preg_match_all($re, $content, $matches, PREG_SET_ORDER|PREG_OFFSET_CAPTURE);

if (! is_array($matches) || count($matches) < 2) {
return [];
}

$lastOffset = 0;

/** @var array $nodes */
$nodes = array_map(function (array $match) use ($content, &$lastOffset) {
// Convert regex offsets to multibyte offsets.
$offset = $match[0][1];

if ($offset !== 0) {
$offset = mb_strpos($content, $match[0][0], $lastOffset + 1);
}

if ($offset === false) {
$offset = $match[0][1];
}

$lastOffset = $offset + 1;

if (str_starts_with($match[0][0], sprintf('@%s', $this->openDirective))) {
$openElement = new OpenFragmentElement();
$openElement->name = $match[1][0];
$openElement->startOffset = $offset;
$openElement->endOffset = $offset + mb_strlen($match[0][0]);

return $openElement;
}

if (str_starts_with($match[0][0], sprintf('@%s', $this->closeDirective))) {
$closeElement = new CloseFragmentElement();
$closeElement->startOffset = $offset;
$closeElement->endOffset = $offset + mb_strlen($match[0][0]);

return $closeElement;
}

return null;
}, $matches);

return array_filter($nodes);
}

private function normalizeLineEndings(string $content): string
{
return str_replace(['\r\n', '\r'], '\n', $content);
}
}
10 changes: 10 additions & 0 deletions src/View/CloseFragmentElement.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?php

declare(strict_types=1);

namespace Mauricius\LaravelHtmx\View;

class CloseFragmentElement extends FragmentElement
{

}
12 changes: 12 additions & 0 deletions src/View/FragmentElement.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?php

declare(strict_types=1);

namespace Mauricius\LaravelHtmx\View;

abstract class FragmentElement
{
public int $startOffset = 0;

public int $endOffset = 0;
}
10 changes: 10 additions & 0 deletions src/View/OpenFragmentElement.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?php

declare(strict_types=1);

namespace Mauricius\LaravelHtmx\View;

class OpenFragmentElement extends FragmentElement
{
public string $name = '';
}
60 changes: 54 additions & 6 deletions tests/FragmentBladeDirectiveTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,30 @@ public function the_view_still_renders_correctly_if_it_contains_fragments()
$this->assertMatchesSnapshot($renderedView);
}

/** @test */
public function it_throws_an_exception_if_the_specified_fragment_does_not_exists_in_the_view()
{
$fragment = 'missing';
$view = 'basic';

$this->expectException(RuntimeException::class);
$this->expectExceptionMessageMatches("/No fragment called \"$fragment\" exists in \".*\/tests\/views\/$view\.blade\.php\"/m");

view()->renderFragment($view, $fragment);
}

/** @test */
public function it_throws_an_exception_if_the_specified_fragment_exists_multiple_times_in_the_view()
{
$fragment = 'duplicate';
$view = 'duplicate';

$this->expectException(RuntimeException::class);
$this->expectExceptionMessageMatches("/Multiple fragments called \"$fragment\" exists in \".*\/tests\/views\/$view\.blade\.php\"/m");

view()->renderFragment($view, $fragment);
}

/** @test */
public function the_render_fragment_view_macro_can_render_a_single_fragment_whose_name_is_enclosed_in_double_quotes()
{
Expand All @@ -42,14 +66,38 @@ public function the_render_fragment_view_macro_can_render_a_single_fragment_whos
}

/** @test */
public function it_throws_an_exception_if_the_specified_fragment_does_not_exists()
public function the_render_fragment_view_macro_can_render_a_single_fragment_defined_inline()
{
$fragment = 'missing';
$view = 'basic';
$message = 'htmx';

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

view()->renderFragment($view, $fragment);
$this->assertMatchesSnapshot($renderedView);
}

/** @test */
public function the_render_fragment_view_macro_can_render_a_single_fragment_even_if_it_is_nested_in_other_fragments()
{
$renderedView = view()->renderFragment('nested', 'inner');

$this->assertMatchesSnapshot($renderedView);
}

/** @test */
public function the_render_fragment_view_macro_can_render_a_single_fragment_even_if_it_is_not_aligned_with_the_closing_fragment()
{
$renderedView = view()->renderFragment('misaligned', 'inner');

$this->assertMatchesSnapshot($renderedView);
}

/** @test */
public function the_render_fragment_view_macro_can_render_a_single_fragment_even_if_it_it_contains_multibyte_characters()
{
$message = 'htmx';

$renderedView = view()->renderFragment('multibyte', 'fünf', compact('message'));

$this->assertMatchesSnapshot($renderedView);
}
}
3 changes: 3 additions & 0 deletions tests/TestCase.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ public function setUp(): void
{
parent::setUp();

// Testbench seems to forget this hint from time to time
View::addNamespace('__components', storage_path('framework/views'));

View::addLocation(__DIR__.'/views');
}

Expand Down
Loading

0 comments on commit 413e0cf

Please sign in to comment.