From c1233aa56fcb443b48eff4b0c15a000d144816f1 Mon Sep 17 00:00:00 2001 From: Brent Roose Date: Fri, 11 Oct 2024 14:25:50 +0200 Subject: [PATCH] chore: refactor view engine (#560) --- .env.example | 3 +- src/Tempest/Cache/composer.json | 3 +- src/Tempest/Cache/src/CacheConfig.php | 5 + src/Tempest/Cache/src/CacheInitializer.php | 2 +- src/Tempest/Cache/src/GenericCache.php | 11 +- src/Tempest/Cache/src/IsCache.php | 6 + src/Tempest/Cache/tests/CacheTest.php | 15 +- src/Tempest/Support/src/StringHelper.php | 46 ++- .../Support/tests/StringHelperTest.php | 22 ++ src/Tempest/View/composer.json | 3 +- src/Tempest/View/src/Attribute.php | 2 +- .../View/src/Attributes/AttributeFactory.php | 9 +- .../View/src/Attributes/DataAttribute.php | 18 +- .../View/src/Attributes/ElseAttribute.php | 30 +- .../View/src/Attributes/ElseIfAttribute.php | 38 +- .../src/Attributes/ExpressionAttribute.php | 29 ++ .../View/src/Attributes/ForeachAttribute.php | 30 +- .../View/src/Attributes/ForelseAttribute.php | 30 +- .../View/src/Attributes/IfAttribute.php | 15 +- .../src/Components/AnonymousViewComponent.php | 5 +- src/Tempest/View/src/Components/Form.php | 5 +- src/Tempest/View/src/Components/Input.php | 5 +- src/Tempest/View/src/Components/Submit.php | 5 +- src/Tempest/View/src/Element.php | 14 +- .../View/src/Elements/CollectionElement.php | 9 +- .../View/src/Elements/ElementFactory.php | 51 ++- .../View/src/Elements/EmptyElement.php | 18 - .../View/src/Elements/GenericElement.php | 115 +----- src/Tempest/View/src/Elements/IsElement.php | 78 +++-- .../View/src/Elements/PhpDataElement.php | 62 ++++ .../View/src/Elements/PhpForeachElement.php | 67 ++++ .../View/src/Elements/PhpIfElement.php | 78 +++++ src/Tempest/View/src/Elements/RawElement.php | 2 +- src/Tempest/View/src/Elements/SlotElement.php | 11 + src/Tempest/View/src/Elements/TextElement.php | 21 +- .../src/Elements/ViewComponentElement.php | 79 +++++ .../View/src/Exceptions/InvalidElement.php | 12 + .../src/Exceptions/ViewCompilationError.php | 50 +++ src/Tempest/View/src/IsView.php | 55 +-- .../src/Renderers/TempestViewCompiler.php | 172 +++++++++ .../src/Renderers/TempestViewRenderer.php | 330 +++--------------- src/Tempest/View/src/View.php | 8 - src/Tempest/View/src/ViewCache.php | 44 +++ src/Tempest/View/src/ViewCachePool.php | 113 ++++++ src/Tempest/View/src/ViewComponent.php | 4 +- src/Tempest/View/src/ViewComponentView.php | 33 -- src/Tempest/View/src/ViewRenderer.php | 2 +- src/Tempest/View/tests/ViewCachePoolTest.php | 138 ++++++++ src/Tempest/View/tests/ViewCacheTest.php | 98 ++++++ tests/Fixtures/BaseLayoutComponent.php | 5 +- tests/Fixtures/Cache/DummyCache.php | 5 + tests/Fixtures/ComplexBaseLayoutComponent.php | 5 +- tests/Fixtures/Controllers/DocsController.php | 15 + tests/Fixtures/MyViewComponent.php | 5 +- .../Fixtures/MyViewComponentWithInjection.php | 5 +- tests/Fixtures/Views/ViewModel.php | 2 +- tests/Fixtures/Views/base.view.php | 8 +- tests/Fixtures/Views/index.view.php | 2 +- tests/Fixtures/Views/raw-escaped.view.php | 5 + tests/Fixtures/Views/rawAndEscaping.php | 8 - ...onent-with-another-one-included-a.view.php | 7 + ...onent-with-another-one-included-b.view.php | 7 + .../view-component-with-use-import.view.php | 10 + .../Views/view-defined-local-vars-a.view.php | 2 +- tests/Fixtures/Views/x-button-usage.view.php | 8 + tests/Fixtures/Views/x-button.view.php | 13 + .../viewComponentWithVariable.view.php | 6 +- .../Http/GenericResponseSenderTest.php | 2 +- tests/Integration/View/ElementFactoryTest.php | 9 +- .../View/TempestViewRendererTest.php | 131 ++++++- tests/Integration/View/ViewComponentTest.php | 147 +++++--- tests/Integration/View/ViewTest.php | 30 +- 72 files changed, 1631 insertions(+), 807 deletions(-) create mode 100644 src/Tempest/View/src/Attributes/ExpressionAttribute.php delete mode 100644 src/Tempest/View/src/Elements/EmptyElement.php create mode 100644 src/Tempest/View/src/Elements/PhpDataElement.php create mode 100644 src/Tempest/View/src/Elements/PhpForeachElement.php create mode 100644 src/Tempest/View/src/Elements/PhpIfElement.php create mode 100644 src/Tempest/View/src/Elements/ViewComponentElement.php create mode 100644 src/Tempest/View/src/Exceptions/InvalidElement.php create mode 100644 src/Tempest/View/src/Exceptions/ViewCompilationError.php create mode 100644 src/Tempest/View/src/Renderers/TempestViewCompiler.php create mode 100644 src/Tempest/View/src/ViewCache.php create mode 100644 src/Tempest/View/src/ViewCachePool.php delete mode 100644 src/Tempest/View/src/ViewComponentView.php create mode 100644 src/Tempest/View/tests/ViewCachePoolTest.php create mode 100644 src/Tempest/View/tests/ViewCacheTest.php create mode 100644 tests/Fixtures/Controllers/DocsController.php create mode 100644 tests/Fixtures/Views/raw-escaped.view.php delete mode 100644 tests/Fixtures/Views/rawAndEscaping.php create mode 100644 tests/Fixtures/Views/view-component-with-another-one-included-a.view.php create mode 100644 tests/Fixtures/Views/view-component-with-another-one-included-b.view.php create mode 100644 tests/Fixtures/Views/view-component-with-use-import.view.php create mode 100644 tests/Fixtures/Views/x-button-usage.view.php create mode 100644 tests/Fixtures/Views/x-button.view.php diff --git a/.env.example b/.env.example index 06d13f667..edff8f1c0 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,4 @@ ENVIRONMENT=production BASE_URI=http://localhost -DISCOVERY_CACHE=false \ No newline at end of file +DISCOVERY_CACHE=false +CACHE=false \ No newline at end of file diff --git a/src/Tempest/Cache/composer.json b/src/Tempest/Cache/composer.json index 5c4acb034..6ad0d0ceb 100644 --- a/src/Tempest/Cache/composer.json +++ b/src/Tempest/Cache/composer.json @@ -4,7 +4,8 @@ "require": { "php": "^8.3", "psr/cache": "^3.0", - "symfony/cache": "^7.2" + "symfony/cache": "^7.2", + "tempest/core": "dev-main" }, "require-dev": { "tempest/clock": "dev-main" diff --git a/src/Tempest/Cache/src/CacheConfig.php b/src/Tempest/Cache/src/CacheConfig.php index 9ac1a7bdf..5d1d91b38 100644 --- a/src/Tempest/Cache/src/CacheConfig.php +++ b/src/Tempest/Cache/src/CacheConfig.php @@ -6,19 +6,24 @@ use Psr\Cache\CacheItemPoolInterface; use Symfony\Component\Cache\Adapter\FilesystemAdapter; +use function Tempest\env; final class CacheConfig { /** @var class-string<\Tempest\Cache\Cache>[] */ public array $caches = []; + public bool $enabled; + public function __construct( public CacheItemPoolInterface $pool = new FilesystemAdapter( namespace: '', defaultLifetime: 0, directory: __DIR__ . '/../../../../.cache', ), + ?bool $enabled = null, ) { + $this->enabled = $enabled ?? env('CACHE', true); } /** @param class-string<\Tempest\Cache\Cache> $className */ diff --git a/src/Tempest/Cache/src/CacheInitializer.php b/src/Tempest/Cache/src/CacheInitializer.php index 51a5b855f..8a8d22b5c 100644 --- a/src/Tempest/Cache/src/CacheInitializer.php +++ b/src/Tempest/Cache/src/CacheInitializer.php @@ -13,6 +13,6 @@ #[Singleton] public function initialize(Container $container): Cache|GenericCache { - return new GenericCache($container->get(CacheConfig::class)->pool); + return new GenericCache($container->get(CacheConfig::class)); } } diff --git a/src/Tempest/Cache/src/GenericCache.php b/src/Tempest/Cache/src/GenericCache.php index 82afb5780..1077d1804 100644 --- a/src/Tempest/Cache/src/GenericCache.php +++ b/src/Tempest/Cache/src/GenericCache.php @@ -6,17 +6,22 @@ use Psr\Cache\CacheItemPoolInterface; -final readonly class GenericCache implements Cache +final class GenericCache implements Cache { use IsCache; public function __construct( - private CacheItemPoolInterface $pool, + private readonly CacheConfig $cacheConfig, ) { } protected function getCachePool(): CacheItemPoolInterface { - return $this->pool; + return $this->cacheConfig->pool; + } + + protected function isEnabled(): bool + { + return $this->cacheConfig->enabled; } } diff --git a/src/Tempest/Cache/src/IsCache.php b/src/Tempest/Cache/src/IsCache.php index e5ec9a1cf..b1ce9fff2 100644 --- a/src/Tempest/Cache/src/IsCache.php +++ b/src/Tempest/Cache/src/IsCache.php @@ -13,6 +13,8 @@ trait IsCache { abstract protected function getCachePool(): CacheItemPoolInterface; + abstract protected function isEnabled(): bool; + public function put(string $key, mixed $value, ?DateTimeInterface $expiresAt = null): CacheItemInterface { $item = $this->getCachePool() @@ -36,6 +38,10 @@ public function get(string $key): mixed /** @param Closure(): mixed $cache */ public function resolve(string $key, Closure $cache, ?DateTimeInterface $expiresAt = null): mixed { + if (! $this->isEnabled()) { + return $cache(); + } + $item = $this->getCachePool()->getItem($key); if (! $item->isHit()) { diff --git a/src/Tempest/Cache/tests/CacheTest.php b/src/Tempest/Cache/tests/CacheTest.php index c041ad6d8..e8d18b96a 100644 --- a/src/Tempest/Cache/tests/CacheTest.php +++ b/src/Tempest/Cache/tests/CacheTest.php @@ -7,6 +7,7 @@ use DateInterval; use PHPUnit\Framework\TestCase; use Symfony\Component\Cache\Adapter\ArrayAdapter; +use Tempest\Cache\CacheConfig; use Tempest\Cache\GenericCache; use Tempest\Clock\MockClock; @@ -19,7 +20,7 @@ public function test_put(): void { $clock = new MockClock(); $pool = new ArrayAdapter(clock: $clock); - $cache = new GenericCache($pool); + $cache = new GenericCache(new CacheConfig($pool)); $interval = new DateInterval('P1D'); $cache->put('a', 'a', $clock->now()->add($interval)); @@ -43,7 +44,7 @@ public function test_get(): void { $clock = new MockClock(); $pool = new ArrayAdapter(clock: $clock); - $cache = new GenericCache($pool); + $cache = new GenericCache(new CacheConfig($pool)); $interval = new DateInterval('P1D'); $cache->put('a', 'a', $clock->now()->add($interval)); @@ -62,7 +63,11 @@ public function test_resolve(): void { $clock = new MockClock(); $pool = new ArrayAdapter(clock: $clock); - $cache = new GenericCache($pool); + $config = new CacheConfig( + pool: $pool, + enabled: true, + ); + $cache = new GenericCache($config); $interval = new DateInterval('P1D'); $a = $cache->resolve('a', fn () => 'a', $clock->now()->add($interval)); @@ -83,7 +88,7 @@ public function test_resolve(): void public function test_remove(): void { $pool = new ArrayAdapter(); - $cache = new GenericCache($pool); + $cache = new GenericCache(new CacheConfig($pool)); $cache->put('a', 'a'); @@ -95,7 +100,7 @@ public function test_remove(): void public function test_clear(): void { $pool = new ArrayAdapter(); - $cache = new GenericCache($pool); + $cache = new GenericCache(new CacheConfig($pool)); $cache->put('a', 'a'); $cache->put('b', 'b'); diff --git a/src/Tempest/Support/src/StringHelper.php b/src/Tempest/Support/src/StringHelper.php index f0cff0bfd..68b6319f5 100644 --- a/src/Tempest/Support/src/StringHelper.php +++ b/src/Tempest/Support/src/StringHelper.php @@ -286,14 +286,34 @@ public function classBasename(): self return new self(basename(str_replace('\\', '/', $this->string))); } - public function startsWith(Stringable|string $needle): bool + public function startsWith(Stringable|string|array $needles): bool { - return str_starts_with($this->string, (string) $needle); + if (! is_array($needles)) { + $needles = [$needles]; + } + + foreach ($needles as $needle) { + if (str_starts_with($this->string, (string) $needle)) { + return true; + } + } + + return false; } - public function endsWith(Stringable|string $needle): bool + public function endsWith(Stringable|string|array $needles): bool { - return str_ends_with($this->string, (string) $needle); + if (! is_array($needles)) { + $needles = [$needles]; + } + + foreach ($needles as $needle) { + if (str_ends_with($this->string, (string) $needle)) { + return true; + } + } + + return false; } public function replaceFirst(Stringable|string $search, Stringable|string $replace): self @@ -420,6 +440,24 @@ public function dump(mixed ...$dumps): self return $this; } + public function excerpt(int $from, int $to, bool $asArray = false): self|ArrayHelper + { + $lines = explode(PHP_EOL, $this->string); + + $from = max(0, $from - 1); + + $to = min($to - 1, count($lines)); + + $lines = array_slice($lines, $from, $to - $from + 1, true); + + if ($asArray) { + return arr($lines) + ->mapWithKeys(fn (string $line, int $number) => yield $number + 1 => $line); + } + + return new self(implode(PHP_EOL, $lines)); + } + private function normalizeString(mixed $value): mixed { if ($value instanceof Stringable) { diff --git a/src/Tempest/Support/tests/StringHelperTest.php b/src/Tempest/Support/tests/StringHelperTest.php index 5bc492c2b..262236538 100644 --- a/src/Tempest/Support/tests/StringHelperTest.php +++ b/src/Tempest/Support/tests/StringHelperTest.php @@ -419,4 +419,26 @@ public function test_implode(): void $this->assertSame('path/to/tempest', StringHelper::implode(arr(['path', 'to', 'tempest']), '/')->toString()); $this->assertSame('john doe', StringHelper::implode(arr(['john', 'doe']))->toString()); } + + public function test_excerpt(): void + { + $content = str('a +b +c +d +e +f +g'); + + $this->assertTrue($content->excerpt(2, 4)->equals('b +c +d')); + + $this->assertTrue($content->excerpt(-10, 2)->equals('a +b')); + + $this->assertTrue($content->excerpt(7, 100)->equals('g')); + + $this->assertSame([2 => 'b', 3 => 'c', 4 => 'd'], $content->excerpt(2, 4, asArray: true)->toArray()); + } } diff --git a/src/Tempest/View/composer.json b/src/Tempest/View/composer.json index 91e9292ac..6f84e29fa 100644 --- a/src/Tempest/View/composer.json +++ b/src/Tempest/View/composer.json @@ -8,7 +8,8 @@ "masterminds/html5": "^2.9", "tempest/core": "dev-main", "tempest/container": "dev-main", - "tempest/validation": "dev-main" + "tempest/validation": "dev-main", + "tempest/cache": "dev-main" }, "autoload": { "files": [ diff --git a/src/Tempest/View/src/Attribute.php b/src/Tempest/View/src/Attribute.php index 74f1984f4..5b34dc98e 100644 --- a/src/Tempest/View/src/Attribute.php +++ b/src/Tempest/View/src/Attribute.php @@ -6,5 +6,5 @@ interface Attribute { - public function apply(Element $element): Element; + public function apply(Element $element): ?Element; } diff --git a/src/Tempest/View/src/Attributes/AttributeFactory.php b/src/Tempest/View/src/Attributes/AttributeFactory.php index 20b56b18c..7ab045da4 100644 --- a/src/Tempest/View/src/Attributes/AttributeFactory.php +++ b/src/Tempest/View/src/Attributes/AttributeFactory.php @@ -5,20 +5,19 @@ namespace Tempest\View\Attributes; use Tempest\View\Attribute; -use Tempest\View\View; final readonly class AttributeFactory { - public function make(View $view, string $name, ?string $value): Attribute + public function make(string $name): Attribute { return match(true) { $name === ':if' => new IfAttribute(), $name === ':elseif' => new ElseIfAttribute(), $name === ':else' => new ElseAttribute(), - $name === ':foreach' => new ForeachAttribute($view, $value), + $name === ':foreach' => new ForeachAttribute(), $name === ':forelse' => new ForelseAttribute(), - str_starts_with(':', $name) && $value => new DataAttribute($view, $name, $value), - default => new DefaultAttribute(), + str_starts_with($name, ':') => new ExpressionAttribute($name), + default => new DataAttribute($name), }; } } diff --git a/src/Tempest/View/src/Attributes/DataAttribute.php b/src/Tempest/View/src/Attributes/DataAttribute.php index 33ae77e30..3f7bdee51 100644 --- a/src/Tempest/View/src/Attributes/DataAttribute.php +++ b/src/Tempest/View/src/Attributes/DataAttribute.php @@ -4,21 +4,31 @@ namespace Tempest\View\Attributes; +use function Tempest\Support\str; use Tempest\View\Attribute; use Tempest\View\Element; -use Tempest\View\View; +use Tempest\View\Elements\PhpDataElement; +use Tempest\View\Elements\ViewComponentElement; final readonly class DataAttribute implements Attribute { public function __construct( - private View $view, private string $name, - private string $eval ) { } public function apply(Element $element): Element { - return $element->addData(...[$this->name => $this->view->eval($this->eval)]); + if (! $element instanceof ViewComponentElement) { + return $element; + } + + $value = str($element->getAttribute($this->name)); + + return new PhpDataElement( + $this->name, + $value->toString(), + $element, + ); } } diff --git a/src/Tempest/View/src/Attributes/ElseAttribute.php b/src/Tempest/View/src/Attributes/ElseAttribute.php index d9a5ba9a0..b6d788c03 100644 --- a/src/Tempest/View/src/Attributes/ElseAttribute.php +++ b/src/Tempest/View/src/Attributes/ElseAttribute.php @@ -4,39 +4,23 @@ namespace Tempest\View\Attributes; -use Exception; use Tempest\View\Attribute; use Tempest\View\Element; -use Tempest\View\Elements\EmptyElement; -use Tempest\View\Elements\GenericElement; +use Tempest\View\Elements\PhpIfElement; +use Tempest\View\Exceptions\InvalidElement; final readonly class ElseAttribute implements Attribute { - public function apply(Element $element): Element + public function apply(Element $element): ?Element { $previous = $element->getPrevious(); - $previousCondition = false; - if (! $previous instanceof GenericElement) { - throw new Exception("Invalid preceding element before :else"); + if (! $previous instanceof PhpIfElement) { + throw new InvalidElement('There needs to be an if or elseif element before.'); } - // Check all :elseif and :if conditions for previous elements - // If one of the previous element's conditions is true, we'll stop. - // We won't have to render this :else element - while ( - $previousCondition === false - && $previous instanceof GenericElement - && ($previous->hasAttribute('if') || $previous->hasAttribute('elseif')) - ) { - $previousCondition = (bool) ($previous->getAttribute('if') ?? $previous->getAttribute('elseif')); - $previous = $previous->getPrevious(); - } - - if ($previousCondition) { - return new EmptyElement(); - } + $previous->setElse($element); - return $element; + return null; } } diff --git a/src/Tempest/View/src/Attributes/ElseIfAttribute.php b/src/Tempest/View/src/Attributes/ElseIfAttribute.php index e7dd5eb2f..bac009513 100644 --- a/src/Tempest/View/src/Attributes/ElseIfAttribute.php +++ b/src/Tempest/View/src/Attributes/ElseIfAttribute.php @@ -6,47 +6,23 @@ namespace Tempest\View\Attributes; -use Exception; use Tempest\View\Attribute; use Tempest\View\Element; -use Tempest\View\Elements\EmptyElement; -use Tempest\View\Elements\GenericElement; +use Tempest\View\Elements\PhpIfElement; +use Tempest\View\Exceptions\InvalidElement; final readonly class ElseIfAttribute implements Attribute { - public function apply(Element $element): Element + public function apply(Element $element): ?Element { - if (! $element instanceof GenericElement) { - throw new Exception("Invalid element with :elseif"); - } - $previous = $element->getPrevious(); - $previousCondition = false; - - if (! $previous instanceof GenericElement) { - throw new Exception("Invalid preceding element before :elseif"); - } - // Check all :elseif and :if conditions for previous elements - // If one of the previous element's conditions is true, we'll stop. - // We won't have to render this :elseif element - while ( - $previousCondition === false - && $previous instanceof GenericElement - && ($previous->hasAttribute('if') || $previous->hasAttribute('elseif')) - ) { - $previousCondition = (bool) ($previous->getAttribute('if') ?? $previous->getAttribute('elseif')); - $previous = $previous->getPrevious(); + if (! $previous instanceof PhpIfElement) { + throw new InvalidElement('There needs to be an if or elseif element before.'); } - $currentCondition = (bool) $element->getAttribute('elseif'); - - // For this element to render, the previous conditions need to be false, - // and the current condition must be true - if ($previousCondition === false && $currentCondition === true) { - return $element; - } + $previous->addElseif($element); - return new EmptyElement(); + return null; } } diff --git a/src/Tempest/View/src/Attributes/ExpressionAttribute.php b/src/Tempest/View/src/Attributes/ExpressionAttribute.php new file mode 100644 index 000000000..5a06663e9 --- /dev/null +++ b/src/Tempest/View/src/Attributes/ExpressionAttribute.php @@ -0,0 +1,29 @@ +name, + $element->getAttribute($this->name), + $element->setAttribute( + $this->name, + sprintf('', $element->getAttribute($this->name)) + ), + ); + } +} diff --git a/src/Tempest/View/src/Attributes/ForeachAttribute.php b/src/Tempest/View/src/Attributes/ForeachAttribute.php index e74d08922..261545244 100644 --- a/src/Tempest/View/src/Attributes/ForeachAttribute.php +++ b/src/Tempest/View/src/Attributes/ForeachAttribute.php @@ -6,38 +6,12 @@ use Tempest\View\Attribute; use Tempest\View\Element; -use Tempest\View\Elements\CollectionElement; -use Tempest\View\View; +use Tempest\View\Elements\PhpForeachElement; final readonly class ForeachAttribute implements Attribute { - public function __construct( - private View $view, - private string $eval, - ) { - } - public function apply(Element $element): Element { - preg_match( - '/\$this->(?\w+) as \$(?\w+)/', - $this->eval, - $matches, - ); - - $collection = $this->view->get($matches['collection']); - $itemName = $matches['item']; - - $elements = []; - - foreach ($collection as $item) { - $elementClone = clone $element; - - $elements[] = $elementClone->addData(...[$itemName => $item]); - } - - return new CollectionElement( - elements: $elements, - ); + return new PhpForeachElement($element); } } diff --git a/src/Tempest/View/src/Attributes/ForelseAttribute.php b/src/Tempest/View/src/Attributes/ForelseAttribute.php index 8528f271e..2128e6238 100644 --- a/src/Tempest/View/src/Attributes/ForelseAttribute.php +++ b/src/Tempest/View/src/Attributes/ForelseAttribute.php @@ -4,39 +4,23 @@ namespace Tempest\View\Attributes; -use Exception; use Tempest\View\Attribute; use Tempest\View\Element; -use Tempest\View\Elements\EmptyElement; -use Tempest\View\Elements\GenericElement; +use Tempest\View\Elements\PhpForeachElement; +use Tempest\View\Exceptions\InvalidElement; final readonly class ForelseAttribute implements Attribute { - public function apply(Element $element): Element + public function apply(Element $element): ?Element { $previous = $element->getPrevious(); - if ( - ! $previous instanceof GenericElement - || ! $previous->hasAttribute('foreach') - ) { - throw new Exception('No valid foreach loop found in preceding element'); + if (! $previous instanceof PhpForeachElement) { + throw new InvalidElement('No valid foreach loop found in preceding element'); } - $foreach = $previous->getAttribute('foreach', eval: false); + $previous->setElse($element); - preg_match( - '/\$this->(?\w+) as \$(?\w+)/', - $foreach, - $matches, - ); - - $collection = $element->getData()[$matches['collection']] ?? []; - - if ($collection) { - return new EmptyElement(); - } - - return $element; + return null; } } diff --git a/src/Tempest/View/src/Attributes/IfAttribute.php b/src/Tempest/View/src/Attributes/IfAttribute.php index 39874dbc7..4027a7538 100644 --- a/src/Tempest/View/src/Attributes/IfAttribute.php +++ b/src/Tempest/View/src/Attributes/IfAttribute.php @@ -6,23 +6,12 @@ use Tempest\View\Attribute; use Tempest\View\Element; -use Tempest\View\Elements\EmptyElement; -use Tempest\View\Elements\GenericElement; +use Tempest\View\Elements\PhpIfElement; final readonly class IfAttribute implements Attribute { public function apply(Element $element): Element { - if (! $element instanceof GenericElement) { - return $element; - } - - $condition = $element->getAttribute('if'); - - if ($condition) { - return $element; - } - - return new EmptyElement(); + return new PhpIfElement($element); } } diff --git a/src/Tempest/View/src/Components/AnonymousViewComponent.php b/src/Tempest/View/src/Components/AnonymousViewComponent.php index c8648209e..4dc235936 100644 --- a/src/Tempest/View/src/Components/AnonymousViewComponent.php +++ b/src/Tempest/View/src/Components/AnonymousViewComponent.php @@ -4,9 +4,8 @@ namespace Tempest\View\Components; -use Tempest\View\Elements\GenericElement; +use Tempest\View\Elements\ViewComponentElement; use Tempest\View\ViewComponent; -use Tempest\View\ViewRenderer; final readonly class AnonymousViewComponent implements ViewComponent { @@ -21,7 +20,7 @@ public static function getName(): string return 'x-component'; } - public function render(GenericElement $element, ViewRenderer $renderer): string + public function compile(ViewComponentElement $element): string { return $this->contents; } diff --git a/src/Tempest/View/src/Components/Form.php b/src/Tempest/View/src/Components/Form.php index ef5ebe2ce..24ab6bb7c 100644 --- a/src/Tempest/View/src/Components/Form.php +++ b/src/Tempest/View/src/Components/Form.php @@ -4,9 +4,8 @@ namespace Tempest\View\Components; -use Tempest\View\Elements\GenericElement; +use Tempest\View\Elements\ViewComponentElement; use Tempest\View\ViewComponent; -use Tempest\View\ViewRenderer; final readonly class Form implements ViewComponent { @@ -15,7 +14,7 @@ public static function getName(): string return 'x-form'; } - public function render(GenericElement $element, ViewRenderer $renderer): string + public function compile(ViewComponentElement $element): string { $action = $element->getAttribute('action'); $method = $element->getAttribute('method') ?? 'post'; diff --git a/src/Tempest/View/src/Components/Input.php b/src/Tempest/View/src/Components/Input.php index 965b9bf0c..75b9ef9f6 100644 --- a/src/Tempest/View/src/Components/Input.php +++ b/src/Tempest/View/src/Components/Input.php @@ -6,9 +6,8 @@ use Tempest\Http\Session\Session; use Tempest\Validation\Rule; -use Tempest\View\Elements\GenericElement; +use Tempest\View\Elements\ViewComponentElement; use Tempest\View\ViewComponent; -use Tempest\View\ViewRenderer; final readonly class Input implements ViewComponent { @@ -22,7 +21,7 @@ public static function getName(): string return 'x-input'; } - public function render(GenericElement $element, ViewRenderer $renderer): string + public function compile(ViewComponentElement $element): string { $name = $element->getAttribute('name'); $label = $element->getAttribute('label'); diff --git a/src/Tempest/View/src/Components/Submit.php b/src/Tempest/View/src/Components/Submit.php index a3c118257..a927757c6 100644 --- a/src/Tempest/View/src/Components/Submit.php +++ b/src/Tempest/View/src/Components/Submit.php @@ -4,9 +4,8 @@ namespace Tempest\View\Components; -use Tempest\View\Elements\GenericElement; +use Tempest\View\Elements\ViewComponentElement; use Tempest\View\ViewComponent; -use Tempest\View\ViewRenderer; final readonly class Submit implements ViewComponent { @@ -15,7 +14,7 @@ public static function getName(): string return 'x-submit'; } - public function render(GenericElement $element, ViewRenderer $renderer): string + public function compile(ViewComponentElement $element): string { $label = $element->getAttribute('label') ?? 'Submit'; diff --git a/src/Tempest/View/src/Element.php b/src/Tempest/View/src/Element.php index 52b1f8003..1bd1f0e64 100644 --- a/src/Tempest/View/src/Element.php +++ b/src/Tempest/View/src/Element.php @@ -6,6 +6,16 @@ interface Element { + public function compile(): string; + + public function getAttributes(): array; + + public function hasAttribute(string $name): bool; + + public function getAttribute(string $name): string|null; + + public function setAttribute(string $name, string $value): self; + public function setPrevious(?Element $previous): self; public function getPrevious(): ?Element; @@ -19,8 +29,4 @@ public function setChildren(array $children): self; /** @return \Tempest\View\Element[] */ public function getChildren(): array; - - public function getData(?string $key = null): mixed; - - public function addData(...$data): self; } diff --git a/src/Tempest/View/src/Elements/CollectionElement.php b/src/Tempest/View/src/Elements/CollectionElement.php index 93611aca0..452901b9c 100644 --- a/src/Tempest/View/src/Elements/CollectionElement.php +++ b/src/Tempest/View/src/Elements/CollectionElement.php @@ -5,7 +5,6 @@ namespace Tempest\View\Elements; use Tempest\View\Element; -use Tempest\View\ViewRenderer; final class CollectionElement implements Element { @@ -22,14 +21,14 @@ public function getElements(): array return $this->elements; } - public function render(ViewRenderer $renderer): string + public function compile(): string { - $rendered = []; + $compiled = []; foreach ($this->elements as $element) { - $rendered[] = $element->render($renderer); + $compiled[] = $element->compile(); } - return implode(PHP_EOL, $rendered); + return implode(PHP_EOL, $compiled); } } diff --git a/src/Tempest/View/src/Elements/ElementFactory.php b/src/Tempest/View/src/Elements/ElementFactory.php index 158888165..9678b53c3 100644 --- a/src/Tempest/View/src/Elements/ElementFactory.php +++ b/src/Tempest/View/src/Elements/ElementFactory.php @@ -8,21 +8,39 @@ use DOMElement; use DOMNode; use DOMText; +use Tempest\Container\Container; +use function Tempest\Support\str; use Tempest\View\Element; -use Tempest\View\View; +use Tempest\View\Renderers\TempestViewCompiler; +use Tempest\View\ViewComponent; +use Tempest\View\ViewConfig; final class ElementFactory { - public function make(View $view, DOMElement $node): ?Element + private TempestViewCompiler $compiler; + + public function __construct( + private readonly ViewConfig $viewConfig, + private readonly Container $container, + ) { + } + + public function setViewCompiler(TempestViewCompiler $compiler): self + { + $this->compiler = $compiler; + + return $this; + } + + public function make(DOMNode $node): ?Element { return $this->makeElement( - view: $view, node: $node, parent: null, ); } - private function makeElement(View $view, DOMNode $node, ?Element $parent): ?Element + private function makeElement(DOMNode $node, ?Element $parent): ?Element { if ($node instanceof DOMText) { if (trim($node->textContent) === '') { @@ -42,7 +60,26 @@ private function makeElement(View $view, DOMNode $node, ?Element $parent): ?Elem return new RawElement($node->ownerDocument->saveHTML($node)); } - if ($node->tagName === 'x-slot') { + if ($viewComponentClass = $this->viewConfig->viewComponents[$node->tagName] ?? null) { + if (! $viewComponentClass instanceof ViewComponent) { + $viewComponentClass = $this->container->get($viewComponentClass); + } + + $attributes = []; + + /** @var DOMAttr $attribute */ + foreach ($node->attributes as $attribute) { + $name = (string)str($attribute->name)->camel(); + + $attributes[$name] = $attribute->value; + } + + $element = new ViewComponentElement( + $this->compiler, + $viewComponentClass, + $attributes + ); + } elseif ($node->tagName === 'x-slot') { $element = new SlotElement( name: $node->getAttribute('name') ?: 'slot', ); @@ -51,13 +88,12 @@ private function makeElement(View $view, DOMNode $node, ?Element $parent): ?Elem /** @var DOMAttr $attribute */ foreach ($node->attributes as $attribute) { - $name = (string) \Tempest\Support\str($attribute->name)->camel(); + $name = (string)str($attribute->name)->camel(); $attributes[$name] = $attribute->value; } $element = new GenericElement( - view: $view, tag: $node->tagName, attributes: $attributes, ); @@ -67,7 +103,6 @@ private function makeElement(View $view, DOMNode $node, ?Element $parent): ?Elem foreach ($node->childNodes as $child) { $childElement = $this->clone()->makeElement( - view: $view, node: $child, parent: $parent, ); diff --git a/src/Tempest/View/src/Elements/EmptyElement.php b/src/Tempest/View/src/Elements/EmptyElement.php deleted file mode 100644 index cab22b6bc..000000000 --- a/src/Tempest/View/src/Elements/EmptyElement.php +++ /dev/null @@ -1,18 +0,0 @@ -attributes = $attributes; } - public function getTag(): string + public function compile(): string { - return $this->tag; - } - - public function getAttributes(): array - { - return $this->attributes; - } - - public function hasAttribute(string $name): bool - { - $name = ltrim($name, ':'); + $content = []; - return array_key_exists($name, $this->attributes) - || array_key_exists(":{$name}", $this->attributes); - } - - public function getAttribute(string $name, bool $eval = true): mixed - { - $name = ltrim($name, ':'); - - foreach ($this->attributes as $attributeName => $value) { - if ($attributeName === $name) { - return $value; - } - - if ($attributeName === ":{$name}") { - if (! $value) { - return null; - } - - if (! $eval) { - return $value; - } - - return $this->eval($value) - ?? $this->getData()[ltrim($value, '$')] - ?? ''; - } - } - - return null; - } - - public function getSlot(string $name = 'slot'): ?Element - { foreach ($this->getChildren() as $child) { - if (! $child instanceof SlotElement) { - continue; - } - - if ($child->matches($name)) { - return $child; - } + $content[] = $child->compile(); } - if ($name === 'slot') { - $elements = []; + $content = implode('', $content); - foreach ($this->getChildren() as $child) { - if ($child instanceof SlotElement) { - continue; - } + $attributes = []; - $elements[] = $child; + foreach ($this->getAttributes() as $name => $value) { + if ($value) { + $attributes[] = $name . '="' . $value . '"'; + } else { + $attributes[] = $name; } - - return new CollectionElement($elements); - } - - return null; - } - - public function getData(?string $key = null): mixed - { - if ($key && $this->hasAttribute($key)) { - return $this->getAttribute($key); } - $parentData = $this->getParent()?->getData() ?? []; + $attributes = implode(' ', $attributes); - $data = [...$this->attributes, ...$this->view->getData(), ...$parentData, ...$this->data]; - - if ($key) { - return $data[$key] ?? null; + if ($attributes !== '') { + $attributes = ' ' . $attributes; } - return $data; - } - - private function eval(string $eval): mixed - { - $data = $this->getData(); - - extract($data, flags: EXTR_SKIP); - - /** @phpstan-ignore-next-line */ - return eval("return {$eval};"); - } - - public function __get(string $name) - { - return $this->getData($name) ?? $this->view->{$name}; - } - - public function __call(string $name, array $arguments) - { - return $this->view->{$name}(...$arguments); + return "<{$this->tag}{$attributes}>{$content}tag}>"; } } diff --git a/src/Tempest/View/src/Elements/IsElement.php b/src/Tempest/View/src/Elements/IsElement.php index 0375b7c1d..01a9ae38d 100644 --- a/src/Tempest/View/src/Elements/IsElement.php +++ b/src/Tempest/View/src/Elements/IsElement.php @@ -5,10 +5,13 @@ namespace Tempest\View\Elements; use Tempest\View\Element; +use Tempest\View\View; /** @phpstan-require-implements \Tempest\View\Element */ trait IsElement { + private View $view; + /** @var Element[] */ private array $children = []; @@ -16,7 +19,49 @@ trait IsElement private ?Element $previous = null; - private array $data = []; + private array $attributes = []; + + public function getAttributes(): array + { + return $this->attributes; + } + + public function hasAttribute(string $name): bool + { + $name = ltrim($name, ':'); + + return + array_key_exists(":{$name}", $this->attributes) || + array_key_exists($name, $this->attributes); + } + + public function getAttribute(string $name): string|null + { + $name = ltrim($name, ':'); + + return $this->attributes[":{$name}"] + ?? $this->attributes[$name] + ?? null; + } + + public function setAttribute(string $name, string $value): self + { + $this->unsetAttribute($name); + + $this->attributes[$name] = $value; + + return $this; + } + + public function unsetAttribute(string $name): self + { + $name = ltrim($name, ':'); + + unset($this->attributes[$name]); + unset($this->attributes[":{$name}"]); + + return $this; + } public function setPrevious(?Element $previous): self { @@ -65,35 +110,4 @@ public function setChildren(array $children): self return $this; } - - public function getData(?string $key = null): mixed - { - $parentData = $this->getParent()?->getData() ?? []; - - $data = [...$parentData, ...$this->data]; - - if ($key) { - return $data[$key] ?? null; - } - - return $data; - } - - public function addData(...$data): self - { - $this->data = [...$this->data, ...$data]; - - return $this; - } - - public function __clone(): void - { - $childClones = []; - - foreach ($this->children as $child) { - $childClones[] = clone $child; - } - - $this->setChildren($childClones); - } } diff --git a/src/Tempest/View/src/Elements/PhpDataElement.php b/src/Tempest/View/src/Elements/PhpDataElement.php new file mode 100644 index 000000000..03b0c99c5 --- /dev/null +++ b/src/Tempest/View/src/Elements/PhpDataElement.php @@ -0,0 +1,62 @@ +name, ':'); + $isExpression = str_starts_with($this->name, ':'); + $value = str($this->value ?? ''); + + // If the value of an attribute is PHP code, it's automatically promoted to an expression with the PHP tags stripped + if ($value->startsWith([TempestViewCompiler::TOKEN_PHP_OPEN, TempestViewCompiler::TOKEN_PHP_SHORT_ECHO])) { + $value = $value->replace(TempestViewCompiler::TOKEN_MAPPING, ''); + $isExpression = true; + } + + $value = $value->toString(); + + // We'll declare the variable in PHP right before the actual element + $variableDeclaration = sprintf( + '$%s = %s;', + $name, + $isExpression + ? $value ?: 'null' + : var_export($value, true), + ); + + // And we'll remove it right after the element, this way we've created a "local scope" + // where the variable is only available to that specific element. + $variableRemoval = sprintf( + 'unset($%s);', + $name, + ); + + return sprintf( + ' +%s + +', + $variableDeclaration, + $this->wrappingElement->compile(), + $variableRemoval, + ); + } +} diff --git a/src/Tempest/View/src/Elements/PhpForeachElement.php b/src/Tempest/View/src/Elements/PhpForeachElement.php new file mode 100644 index 000000000..3550c7ace --- /dev/null +++ b/src/Tempest/View/src/Elements/PhpForeachElement.php @@ -0,0 +1,67 @@ +else !== null) { + throw new InvalidElement('There can only be one forelse element.'); + } + + $this->else = $element; + + return $this; + } + + public function compile(): string + { + $compiled = sprintf( + ' +%s', + $this->wrappingElement->getAttribute('foreach'), + $this->wrappingElement->compile(), + ); + + + $compiled = sprintf( + '%s +', + $compiled, + ); + + if ($this->else !== null) { + $collectionName = str($this->wrappingElement->getAttribute('foreach')) + ->match('/^(?.*)\s+as/')['match']; + + $compiled = sprintf( + ' +%s + +%s +', + $collectionName, + $compiled, + $this->else->compile(), + ); + } + + return $compiled; + } +} diff --git a/src/Tempest/View/src/Elements/PhpIfElement.php b/src/Tempest/View/src/Elements/PhpIfElement.php new file mode 100644 index 000000000..587e927c5 --- /dev/null +++ b/src/Tempest/View/src/Elements/PhpIfElement.php @@ -0,0 +1,78 @@ +elseif[] = $element; + + return $this; + } + + public function setElse(Element $element): self + { + if ($this->else !== null) { + throw new InvalidElement('There can only be one else element.'); + } + + $this->else = $element; + + return $this; + } + + public function compile(): string + { + $compiled = sprintf( + " + %s", + $this->wrappingElement->getAttribute('if'), + $this->wrappingElement->compile(), + ); + + foreach ($this->elseif as $elseif) { + $compiled = sprintf( + "%s + + %s", + $compiled, + $elseif->getAttribute('elseif'), + $elseif->compile(), + ); + } + + if ($this->else !== null) { + $compiled = sprintf( + "%s + + %s", + $compiled, + $this->else->compile(), + ); + } + + return sprintf( + "%s + ", + $compiled + ); + } +} diff --git a/src/Tempest/View/src/Elements/RawElement.php b/src/Tempest/View/src/Elements/RawElement.php index 1eb18a50d..793a622f0 100644 --- a/src/Tempest/View/src/Elements/RawElement.php +++ b/src/Tempest/View/src/Elements/RawElement.php @@ -15,7 +15,7 @@ public function __construct( ) { } - public function getHtml(): string + public function compile(): string { return $this->html; } diff --git a/src/Tempest/View/src/Elements/SlotElement.php b/src/Tempest/View/src/Elements/SlotElement.php index 98da88833..c495803cb 100644 --- a/src/Tempest/View/src/Elements/SlotElement.php +++ b/src/Tempest/View/src/Elements/SlotElement.php @@ -19,4 +19,15 @@ public function matches(string $name): bool { return $this->name === $name; } + + public function compile(): string + { + $rendered = []; + + foreach ($this->getChildren() as $child) { + $rendered[] = $child->compile(); + } + + return implode(PHP_EOL, $rendered); + } } diff --git a/src/Tempest/View/src/Elements/TextElement.php b/src/Tempest/View/src/Elements/TextElement.php index dbce1ae09..2c17fa42d 100644 --- a/src/Tempest/View/src/Elements/TextElement.php +++ b/src/Tempest/View/src/Elements/TextElement.php @@ -4,6 +4,7 @@ namespace Tempest\View\Elements; +use function Tempest\Support\str; use Tempest\View\Element; final class TextElement implements Element @@ -15,8 +16,24 @@ public function __construct( ) { } - public function getText(): string + public function compile(): string { - return $this->text; + return str($this->text) + // Render {{ + ->replaceRegex( + regex: '/{{(?.*?)}}/', + replace: function (array $matches): string { + return sprintf('escape(%s); ?>', $matches['match']); + }, + ) + + // Render {!! + ->replaceRegex( + regex: '/{!!(?.*?)!!}/', + replace: function (array $matches): string { + return sprintf('', $matches['match']); + }, + ) + ->toString(); } } diff --git a/src/Tempest/View/src/Elements/ViewComponentElement.php b/src/Tempest/View/src/Elements/ViewComponentElement.php new file mode 100644 index 000000000..da47d4cac --- /dev/null +++ b/src/Tempest/View/src/Elements/ViewComponentElement.php @@ -0,0 +1,79 @@ +attributes = $attributes; + } + + public function getViewComponent(): ViewComponent + { + return $this->viewComponent; + } + + public function getSlot(string $name = 'slot'): ?Element + { + foreach ($this->getChildren() as $child) { + if (! $child instanceof SlotElement) { + continue; + } + + if ($child->matches($name)) { + return $child; + } + } + + if ($name === 'slot') { + $elements = []; + + foreach ($this->getChildren() as $child) { + if ($child instanceof SlotElement) { + continue; + } + + $elements[] = $child; + } + + return new CollectionElement($elements); + } + + return null; + } + + public function compile(): string + { + $compiled = str($this->viewComponent->compile($this)) + // Compile slots + ->replaceRegex( + regex: '/\w+)")?((\s*\/>)|><\/x-slot>)/', + replace: function ($matches) { + $name = $matches['name'] ?: 'slot'; + + $slot = $this->getSlot($name); + + if ($slot === null) { + return $matches[0]; + } + + return $slot->compile(); + }, + ); + + return $this->compiler->compile($compiled->toString()); + } +} diff --git a/src/Tempest/View/src/Exceptions/InvalidElement.php b/src/Tempest/View/src/Exceptions/InvalidElement.php new file mode 100644 index 000000000..45db53b28 --- /dev/null +++ b/src/Tempest/View/src/Exceptions/InvalidElement.php @@ -0,0 +1,12 @@ +excerpt( + $previous->getLine() - 5, + $previous->getLine() + 5, + asArray: true, + ) + ->map(function (string $line, int $number) use ($previous) { + return sprintf( + "%s%s | %s", + $number === $previous->getLine() ? '> ' : ' ', + $number, + $line + ); + }) + ->implode(PHP_EOL); + + $message = sprintf( + '%s +%s +%s +%s + +Could not compile %s', + str_repeat('-', strlen($previous->getMessage())), + $previous->getMessage(), + str_repeat('-', strlen($previous->getMessage())), + $excerpt, + $content, + ); + + parent::__construct( + message: $message, + previous: $previous, + ); + } +} diff --git a/src/Tempest/View/src/IsView.php b/src/Tempest/View/src/IsView.php index 4df828bef..b566026d3 100644 --- a/src/Tempest/View/src/IsView.php +++ b/src/Tempest/View/src/IsView.php @@ -11,26 +11,12 @@ trait IsView public array $data = []; - private array $rawData = []; - public function __construct( string $path, array $data = [], ) { $this->path = $path; - $this->data = $this->escape($data); - $this->rawData = $data; - } - - public function __get(string $name) - { - $value = $this->data[$name] ?? null; - - if (is_string($value)) { - return htmlentities($value); - } - - return $value; + $this->data = $data; } public function getPath(): string @@ -43,16 +29,6 @@ public function getData(): array return $this->data; } - public function getRawData(): array - { - return $this->rawData; - } - - public function getRaw(string $key): mixed - { - return $this->rawData[$key] ?? null; - } - public function get(string $key): mixed { return $this->{$key} ?? $this->data[$key] ?? null; @@ -65,35 +41,8 @@ public function has(string $key): bool public function data(mixed ...$params): self { - $this->rawData = [...$this->rawData, ...$params]; - $this->data = [...$this->data, ...$this->escape($params)]; + $this->data = [...$this->data, ...$params]; return $this; } - - public function raw(string $name): ?string - { - return $this->rawData[$name] ?? null; - } - - private function escape(array $items): array - { - foreach ($items as $key => $value) { - if (! is_string($value)) { - continue; - } - - $items[$key] = htmlentities($value); - } - - return $items; - } - - public function eval(string $eval): mixed - { - extract($this->data, flags: EXTR_SKIP); - - /** @phpstan-ignore-next-line */ - return eval("return {$eval};"); - } } diff --git a/src/Tempest/View/src/Renderers/TempestViewCompiler.php b/src/Tempest/View/src/Renderers/TempestViewCompiler.php new file mode 100644 index 000000000..fc54d4465 --- /dev/null +++ b/src/Tempest/View/src/Renderers/TempestViewCompiler.php @@ -0,0 +1,172 @@ + self::TOKEN_PHP_OPEN, + ' self::TOKEN_PHP_SHORT_ECHO, + '?>' => self::TOKEN_PHP_CLOSE, + ]; + + public function __construct( + private ElementFactory $elementFactory, + private AttributeFactory $attributeFactory, + private Kernel $kernel, + ) { + } + + public function compile(string $path): string + { + // 1. Retrieve template + $template = $this->retrieveTemplate($path); + + // 2. Parse as DOM + $dom = $this->parseDom($template); + + // 3. Map to elements + $elements = $this->mapToElements($dom); + + // 4. Apply attributes + $elements = $this->applyAttributes($elements); + + // 5. Compile to PHP + $compiled = $this->compileElements($elements); + + return $compiled; + } + + private function retrieveTemplate(string $path): string + { + if (! str_ends_with($path, '.php')) { + return $path; + } + + $discoveryLocations = $this->kernel->discoveryLocations; + + $searchPath = $path; + + while (! file_exists($searchPath) && $location = current($discoveryLocations)) { + $searchPath = path($location->path, $path); + next($discoveryLocations); + } + + if (! file_exists($searchPath)) { + throw new Exception("View {$searchPath} not found"); + } + + return file_get_contents($searchPath); + } + + private function parseDom(string $template): DOMNodeList + { + $template = str_replace( + search: array_keys(self::TOKEN_MAPPING), + replace: array_values(self::TOKEN_MAPPING), + subject: $template, + ); + + $html5 = new HTML5(); + + $dom = $html5->loadHTML("
{$template}
"); + + return $dom->getElementById('tempest_render')->childNodes; + } + + /** + * @return Element[] + */ + private function mapToElements(DOMNodeList $domNodeList): array + { + $elements = []; + + foreach ($domNodeList as $node) { + $element = $this->elementFactory + ->setViewCompiler($this) + ->make($node); + + if ($element === null) { + continue; + } + + $elements[] = $element; + } + + return $elements; + } + + /** + * @param Element[] $elements + * @return Element[] + */ + private function applyAttributes(array $elements): array + { + $appliedElements = []; + + $previous = null; + + foreach ($elements as $element) { + $children = $this->applyAttributes($element->getChildren()); + + $element + ->setPrevious($previous) + ->setChildren($children); + + foreach ($element->getAttributes() as $name => $value) { + $attribute = $this->attributeFactory->make($name); + + $element = $attribute->apply($element); + + if ($element === null) { + break; + } + } + + if ($element === null) { + continue; + } + + $appliedElements[] = $element; + + $previous = $element; + } + + return $appliedElements; + } + + /** @param \Tempest\View\Element[] $elements */ + private function compileElements(array $elements): string + { + $compiled = []; + + foreach ($elements as $element) { + $compiled[] = $element->compile(); + } + + $compiled = implode(PHP_EOL, $compiled); + + return str_replace( + search: array_values(self::TOKEN_MAPPING), + replace: array_keys(self::TOKEN_MAPPING), + subject: $compiled, + ); + } +} diff --git a/src/Tempest/View/src/Renderers/TempestViewRenderer.php b/src/Tempest/View/src/Renderers/TempestViewRenderer.php index 31584d0bf..ad39e5d70 100644 --- a/src/Tempest/View/src/Renderers/TempestViewRenderer.php +++ b/src/Tempest/View/src/Renderers/TempestViewRenderer.php @@ -4,38 +4,23 @@ namespace Tempest\View\Renderers; -use Exception; -use Masterminds\HTML5; -use ParseError; -use Tempest\Container\Container; -use Tempest\Core\Kernel; -use function Tempest\path; -use Tempest\View\Attributes\AttributeFactory; -use Tempest\View\Element; -use Tempest\View\Elements\CollectionElement; -use Tempest\View\Elements\ElementFactory; -use Tempest\View\Elements\EmptyElement; -use Tempest\View\Elements\GenericElement; -use Tempest\View\Elements\RawElement; -use Tempest\View\Elements\SlotElement; -use Tempest\View\Elements\TextElement; +use Stringable; +use function Tempest\Support\arr; +use function Tempest\Support\str; +use Tempest\View\Exceptions\ViewCompilationError; use Tempest\View\GenericView; use Tempest\View\View; -use Tempest\View\ViewComponent; -use Tempest\View\ViewComponentView; -use Tempest\View\ViewConfig; +use Tempest\View\ViewCache; use Tempest\View\ViewRenderer; +use Throwable; final class TempestViewRenderer implements ViewRenderer { private ?View $currentView = null; public function __construct( - private readonly ElementFactory $elementFactory, - private readonly AttributeFactory $attributeFactory, - private readonly Kernel $kernel, - private readonly ViewConfig $viewConfig, - private readonly Container $container, + private readonly TempestViewCompiler $compiler, + private readonly ViewCache $viewCache, ) { } @@ -49,262 +34,62 @@ public function __call(string $name, array $arguments) return $this->currentView?->{$name}(...$arguments); } - public function render(string|View|null $view): string + public function render(string|View $view): string { - if ($view === null) { - return ''; - } - - if (is_string($view)) { - $view = new GenericView($view); - } - - $this->currentView = $view; - - $contents = $this->resolveContent($view); - - $html5 = new HTML5(); - $dom = $html5->loadHTML("
{$contents}
"); - - $element = $this->elementFactory->make( - $view, - $dom->getElementById('tempest_render'), - ); + $view = is_string($view) ? new GenericView($view) : $view; - $element = $this->applyAttributes( - view: $view, - element: $element, + $path = $this->viewCache->getCachedViewPath( + path: $view->getPath(), + compiledView: fn () => $this->cleanupCompiled($this->compiler->compile($view->getPath())) ); - return trim($this->renderElements($view, $element->getChildren())); + return $this->renderCompiled($view, $path); } - /** @param \Tempest\View\Element[] $elements */ - private function renderElements(View $view, array $elements): string + private function cleanupCompiled(string $compiled): string { - $rendered = []; + // Remove strict type declarations + $compiled = str($compiled)->replace('declare(strict_types=1);', ''); - foreach ($elements as $element) { - $rendered[] = $this->renderElement($view, $element); - } - - return implode(PHP_EOL, $rendered); - } - - public function renderElement(View $view, Element $element): string - { - if ($element instanceof CollectionElement) { - return $this->renderCollectionElement($view, $element); - } - - if ($element instanceof TextElement) { - return $this->renderTextElement($view, $element); - } + // Cleanup and bundle imports + $imports = arr(); + $compiled = $compiled + ->replaceRegex('/use .*;/', function (array $matches) use (&$imports) { + $imports[$matches[0]] = $matches[0]; - if ($element instanceof EmptyElement) { - return $this->renderEmptyElement(); - } - - if ($element instanceof SlotElement) { - return $this->renderSlotElement($view, $element); - } - - if ($element instanceof RawElement) { - return $this->renderRawElement($element); - } - - if ($element instanceof GenericElement) { - $viewComponent = $this->resolveViewComponent($element); - - if ($viewComponent === null) { - return $this->renderGenericElement($view, $element); - } - - return $this->renderViewComponent( - view: $view, - viewComponent: $viewComponent, - element: $element, + return ''; + }) + ->prepend( + sprintf( + '', + $imports->implode(PHP_EOL), + ), ); - } - - throw new Exception("No rendered found"); - } - - private function resolveContent(View $view): string - { - $path = $view->getPath(); - - if (! str_ends_with($path, '.php')) { - return $this->evalContentIsolated($view, $path); - } - - $discoveryLocations = $this->kernel->discoveryLocations; - - while (! file_exists($path) && $location = current($discoveryLocations)) { - $path = path($location->path, $view->getPath()); - next($discoveryLocations); - } - - if (! file_exists($path)) { - throw new Exception("View {$path} not found"); - } - - return $this->resolveContentIsolated($view, $path); - } - - private function resolveViewComponent(GenericElement $element): ?ViewComponent - { - /** @var class-string<\Tempest\View\ViewComponent>|\Tempest\View\ViewComponent|null $viewComponentClass */ - $viewComponentClass = $this->viewConfig->viewComponents[$element->getTag()] ?? null; - - if (! $viewComponentClass) { - return null; - } - - if ($viewComponentClass instanceof ViewComponent) { - return $viewComponentClass; - } - - return $this->container->get($viewComponentClass); - } - - private function applyAttributes(View $view, Element $element): Element - { - if (! $element instanceof GenericElement) { - return $element; - } - - $children = []; - - foreach ($element->getChildren() as $child) { - $children[] = $this->applyAttributes($view, $child); - } - - $element->setChildren($children); - - foreach ($element->getAttributes() as $name => $value) { - $attribute = $this->attributeFactory->make($view, $name, $value); - - $element = $attribute->apply($element); - } - - return $element; - } - - private function renderTextElement(View $view, TextElement $element): string - { - return preg_replace_callback( - pattern: '/{{\s*(?\$.*?)\s*}}/', - callback: function (array $matches) use ($element, $view): string { - $eval = $matches['eval']; - if (str_starts_with($eval, '$this->')) { - return $view->eval($eval) ?? ''; - } + // Remove empty PHP blocks + $compiled = $compiled->replaceRegex('/<\?php\s*\?>/', ''); - return $element->getData()[ltrim($eval, '$')] ?? ''; - }, - subject: $element->getText(), - ); - } - - private function renderRawElement(RawElement $element): string - { - return $element->getHtml(); - } - - private function renderCollectionElement(View $view, CollectionElement $collectionElement): string - { - $rendered = []; - - foreach ($collectionElement->getElements() as $element) { - $rendered[] = $this->renderElement($view, $element); - } - - return implode(PHP_EOL, $rendered); + return $compiled->toString(); } - private function renderViewComponent(View $view, ViewComponent $viewComponent, GenericElement $element): string + private function renderCompiled(View $_view, string $_path): string { - $renderedContent = preg_replace_callback( - pattern: '/\w+)")?((\s*\/>)|><\/x-slot>)/', - callback: function ($matches) use ($view, $element) { - $name = $matches['name'] ?: 'slot'; - - $slot = $element->getSlot($name); - - if ($slot === null) { - return $matches[0]; - } - - return $this->renderElement($view, $slot); - }, - subject: $viewComponent->render($element, $this), - ); - - return $this->render(new ViewComponentView( - wrappingView: $view, - wrappingElement: $element, - content: $renderedContent, - )); - } - - private function renderEmptyElement(): string - { - return ''; - } - - private function renderSlotElement(View $view, SlotElement $element): string - { - $rendered = []; - - foreach ($element->getChildren() as $child) { - $rendered[] = $this->renderElement($view, $child); - } - - return implode(PHP_EOL, $rendered); - } - - private function renderGenericElement(View $view, GenericElement $element): string - { - $content = []; - - foreach ($element->getChildren() as $child) { - $content[] = $this->renderElement($view, $child); - } - - $content = implode('', $content); - - $attributes = []; + $this->currentView = $_view; - foreach ($element->getAttributes() as $name => $value) { - if ($value) { - $attributes[] = $name . '="' . $value . '"'; - } else { - $attributes[] = $name; - } - } - - $attributes = implode(' ', $attributes); - - if ($attributes !== '') { - $attributes = ' ' . $attributes; - } - - return "<{$element->getTag()}{$attributes}>{$content}getTag()}>"; - } - - private function resolveContentIsolated(View $_view, string $_path): string - { ob_start(); + // Extract data from view into local variables so that they can be accessed directly $_data = $_view->getData(); extract($_data, flags: EXTR_SKIP); - include $_path; - - $content = ob_get_clean(); + try { + include $_path; + } catch (Throwable $throwable) { + throw new ViewCompilationError(content: file_get_contents($_path), previous: $throwable); + } // If the view defines local variables, we add them here to the view object as well foreach (get_defined_vars() as $key => $value) { @@ -313,34 +98,13 @@ private function resolveContentIsolated(View $_view, string $_path): string } } - return $content; + $this->currentView = null; + + return trim(ob_get_clean()); } - private function evalContentIsolated(View $_view, string $_content): string + public function escape(null|string|Stringable $value): string { - ob_start(); - - $_data = $_view->getData(); - - extract($_data, flags: EXTR_SKIP); - - try { - // TODO: find a better way of dealing with views that declare strict types - $_content = str_replace('declare(strict_types=1);', '', $_content); - - /** @phpstan-ignore-next-line */ - eval('?>' . $_content . ' $value) { - if (! $_view->has($key)) { - $_view->data(...[$key => $value]); - } - } - - return ob_get_clean(); + return htmlentities((string)$value); } } diff --git a/src/Tempest/View/src/View.php b/src/Tempest/View/src/View.php index 427a88c17..2094c3d85 100644 --- a/src/Tempest/View/src/View.php +++ b/src/Tempest/View/src/View.php @@ -10,17 +10,9 @@ public function getPath(): string; public function getData(): array; - public function getRawData(): array; - - public function getRaw(string $key): mixed; - public function get(string $key): mixed; public function has(string $key): bool; public function data(...$params): self; - - public function raw(string $name): ?string; - - public function eval(string $eval): mixed; } diff --git a/src/Tempest/View/src/ViewCache.php b/src/Tempest/View/src/ViewCache.php new file mode 100644 index 000000000..626209250 --- /dev/null +++ b/src/Tempest/View/src/ViewCache.php @@ -0,0 +1,44 @@ +cachePool->getItem($cacheKey); + + if ($this->cacheConfig->enabled === false || $cacheItem->isHit() === false) { + $cacheItem = $this->put($cacheKey, $compiledView()); + } + + return path($this->cachePool->directory, $cacheItem->getKey() . '.php'); + } + + protected function getCachePool(): CacheItemPoolInterface + { + return $this->cachePool; + } + + protected function isEnabled(): bool + { + return $this->cacheConfig->enabled; + } +} diff --git a/src/Tempest/View/src/ViewCachePool.php b/src/Tempest/View/src/ViewCachePool.php new file mode 100644 index 000000000..ed011ebc2 --- /dev/null +++ b/src/Tempest/View/src/ViewCachePool.php @@ -0,0 +1,113 @@ +key = $key; + $item->isTaggable = true; + $item->isHit = $isHit; + $item->value = $value; + + return $item; + }, + newThis: null, + newScope: CacheItem::class + ); + + return $createCacheItem($key, $this->makePath($key), $this->hasItem($key)); + } + + /** + * @return ArrayHelper + */ + public function getItems(array $keys = []): ArrayHelper + { + return arr($keys) + ->map(fn (string $key) => $this->getItem($key)); + } + + public function hasItem(string $key): bool + { + return file_exists($this->makePath($key)); + } + + public function clear(): bool + { + if (is_dir($this->directory)) { + /** @phpstan-ignore-next-line */ + arr(glob(path($this->directory, '/*.php')))->each(fn (string $file) => unlink($file)); + + rmdir($this->directory); + } + + return true; + } + + public function deleteItem(string $key): bool + { + @unlink($this->makePath($key)); + + return true; + } + + public function deleteItems(array $keys): bool + { + foreach ($keys as $key) { + $this->deleteItem($key); + } + + return true; + } + + public function save(CacheItemInterface $item): bool + { + $path = $this->makePath($item); + + if (! is_dir(dirname($path))) { + mkdir(dirname($path), recursive: true); + } + + file_put_contents($path, $item->get()); + + return true; + } + + public function saveDeferred(CacheItemInterface $item): bool + { + throw new Exception('Not supported'); + } + + public function commit(): bool + { + throw new Exception('Not supported'); + } + + private function makePath(CacheItemInterface|string $key): string + { + $key = is_string($key) ? $key : $key->getKey(); + + return path($this->directory, "/{$key}.php"); + } +} diff --git a/src/Tempest/View/src/ViewComponent.php b/src/Tempest/View/src/ViewComponent.php index bf94c09fa..09ef3086e 100644 --- a/src/Tempest/View/src/ViewComponent.php +++ b/src/Tempest/View/src/ViewComponent.php @@ -4,11 +4,11 @@ namespace Tempest\View; -use Tempest\View\Elements\GenericElement; +use Tempest\View\Elements\ViewComponentElement; interface ViewComponent { public static function getName(): string; - public function render(GenericElement $element, ViewRenderer $renderer): string; + public function compile(ViewComponentElement $element): string; } diff --git a/src/Tempest/View/src/ViewComponentView.php b/src/Tempest/View/src/ViewComponentView.php deleted file mode 100644 index e333c2fc4..000000000 --- a/src/Tempest/View/src/ViewComponentView.php +++ /dev/null @@ -1,33 +0,0 @@ -path = $content; - } - - public function getData(): array - { - return $this->wrappingElement->getData(); - } - - public function __get(string $name): mixed - { - return $this->wrappingElement->getData($name); - } - - public function __call(string $name, array $arguments) - { - return $this->wrappingView->{$name}(...$arguments); - } -} diff --git a/src/Tempest/View/src/ViewRenderer.php b/src/Tempest/View/src/ViewRenderer.php index 23adafd9f..30b8d65d1 100644 --- a/src/Tempest/View/src/ViewRenderer.php +++ b/src/Tempest/View/src/ViewRenderer.php @@ -6,5 +6,5 @@ interface ViewRenderer { - public function render(string|View|null $view): string; + public function render(string|View $view): string; } diff --git a/src/Tempest/View/tests/ViewCachePoolTest.php b/src/Tempest/View/tests/ViewCachePoolTest.php new file mode 100644 index 000000000..a88562fc1 --- /dev/null +++ b/src/Tempest/View/tests/ViewCachePoolTest.php @@ -0,0 +1,138 @@ +pool = new ViewCachePool( + directory: self::DIRECTORY, + ); + } + + protected function tearDown(): void + { + if (is_dir(self::DIRECTORY)) { + /** @phpstan-ignore-next-line */ + arr(glob(path(self::DIRECTORY, '/*.php')))->each(fn (string $file) => unlink($file)); + + rmdir(self::DIRECTORY); + } + + parent::tearDown(); + } + + public function test_get_item(): void + { + $item = $this->pool->getItem('test'); + $item->set('hi'); + + $this->pool->save($item); + + $this->assertFileExists(path(self::DIRECTORY, 'test.php')); + $this->assertEquals('hi', file_get_contents(path(self::DIRECTORY, 'test.php'))); + } + + public function test_has_item(): void + { + $item = $this->pool->getItem('test'); + $item->set('hi'); + + $this->pool->save($item); + + $this->assertTrue($this->pool->hasItem('test')); + $this->assertFalse($this->pool->hasItem('test-1')); + } + + public function test_get_items(): void + { + $items = $this->pool->getItems(['a', 'b']); + + $this->assertCount(2, $items); + + $this->assertFalse($items[0]->isHit()); + $this->assertFalse($items[1]->isHit()); + + $items[0]->set('hi'); + $this->pool->save($items[0]); + + $items = $this->pool->getItems(['a', 'b']); + + $this->assertTrue($items[0]->isHit()); + $this->assertFalse($items[1]->isHit()); + } + + public function test_delete_item(): void + { + $item = $this->pool->getItem('test'); + $item->set('hi'); + + $this->pool->save($item); + $this->pool->deleteItem('test'); + + $this->assertFileDoesNotExist(path(self::DIRECTORY, 'test.php')); + } + + public function test_delete_items(): void + { + $items = $this->pool->getItems(['a', 'b']); + + $items[0]->set('hi'); + $this->pool->save($items[0]); + + $items[1]->set('hi'); + $this->pool->save($items[1]); + + $this->assertFileExists(path(self::DIRECTORY, 'a.php')); + $this->assertFileExists(path(self::DIRECTORY, 'b.php')); + + $this->pool->deleteItems(['a', 'b']); + + $this->assertFileDoesNotExist(path(self::DIRECTORY, 'a.php')); + $this->assertFileDoesNotExist(path(self::DIRECTORY, 'b.php')); + } + + public function test_clear_pool(): void + { + $item = $this->pool->getItem('test'); + $item->set('hi'); + + $this->pool->save($item); + $this->pool->clear(); + + $this->assertFileDoesNotExist(path(self::DIRECTORY, 'test.php')); + $this->assertDirectoryDoesNotExist(path(self::DIRECTORY)); + } + + public function test_save_deferred(): void + { + $this->expectException(Exception::class); + + $this->pool->saveDeferred($this->pool->getItem('test')); + } + + public function test_commit(): void + { + $this->expectException(Exception::class); + + $this->pool->commit(); + } +} diff --git a/src/Tempest/View/tests/ViewCacheTest.php b/src/Tempest/View/tests/ViewCacheTest.php new file mode 100644 index 000000000..5bb0013ae --- /dev/null +++ b/src/Tempest/View/tests/ViewCacheTest.php @@ -0,0 +1,98 @@ +cacheConfig = new CacheConfig(); + + $this->cache = new ViewCache( + $this->cacheConfig, + new ViewCachePool( + directory: self::DIRECTORY, + ), + ); + } + + protected function tearDown(): void + { + if (is_dir(self::DIRECTORY)) { + /** @phpstan-ignore-next-line */ + arr(glob(path(self::DIRECTORY, '/*.php')))->each(fn (string $file) => unlink($file)); + + rmdir(self::DIRECTORY); + } + + parent::tearDown(); + } + + public function test_view_cache(): void + { + $path = $this->cache->getCachedViewPath('path', fn () => 'hi'); + + $this->assertFileExists($path); + $this->assertSame('hi', file_get_contents($path)); + } + + public function test_view_cache_when_disabled(): void + { + $hit = 0; + + $this->cacheConfig->enabled = false; + + $compileFunction = function () use (&$hit) { + $hit += 1; + + return 'hi'; + }; + + $this->cache->getCachedViewPath('path', $compileFunction); + $path = $this->cache->getCachedViewPath('path', $compileFunction); + + $this->assertFileExists($path); + $this->assertSame('hi', file_get_contents($path)); + $this->assertSame(2, $hit); + } + + public function test_view_cache_when_enabled(): void + { + $hit = 0; + + $this->cacheConfig->enabled = true; + + $compileFunction = function () use (&$hit) { + $hit += 1; + + return 'hi'; + }; + + $this->cache->getCachedViewPath('path', $compileFunction); + $path = $this->cache->getCachedViewPath('path', $compileFunction); + + $this->assertFileExists($path); + $this->assertSame('hi', file_get_contents($path)); + $this->assertSame(1, $hit); + } +} diff --git a/tests/Fixtures/BaseLayoutComponent.php b/tests/Fixtures/BaseLayoutComponent.php index 518aa6430..ce5c4f010 100644 --- a/tests/Fixtures/BaseLayoutComponent.php +++ b/tests/Fixtures/BaseLayoutComponent.php @@ -4,9 +4,8 @@ namespace Tests\Tempest\Fixtures; -use Tempest\View\Elements\GenericElement; +use Tempest\View\Elements\ViewComponentElement; use Tempest\View\ViewComponent; -use Tempest\View\ViewRenderer; final readonly class BaseLayoutComponent implements ViewComponent { @@ -15,7 +14,7 @@ public static function getName(): string return 'x-base-layout'; } - public function render(GenericElement $element, ViewRenderer $renderer): string + public function compile(ViewComponentElement $element): string { return << diff --git a/tests/Fixtures/Cache/DummyCache.php b/tests/Fixtures/Cache/DummyCache.php index 50ebd88a4..ec2761166 100644 --- a/tests/Fixtures/Cache/DummyCache.php +++ b/tests/Fixtures/Cache/DummyCache.php @@ -31,4 +31,9 @@ public function clear(): void { $this->cleared = true; } + + protected function isEnabled(): bool + { + return true; + } } diff --git a/tests/Fixtures/ComplexBaseLayoutComponent.php b/tests/Fixtures/ComplexBaseLayoutComponent.php index 9aad08f37..74ab60a0d 100644 --- a/tests/Fixtures/ComplexBaseLayoutComponent.php +++ b/tests/Fixtures/ComplexBaseLayoutComponent.php @@ -4,9 +4,8 @@ namespace Tests\Tempest\Fixtures; -use Tempest\View\Elements\GenericElement; +use Tempest\View\Elements\ViewComponentElement; use Tempest\View\ViewComponent; -use Tempest\View\ViewRenderer; final readonly class ComplexBaseLayoutComponent implements ViewComponent { @@ -15,7 +14,7 @@ public static function getName(): string return 'x-complex-base'; } - public function render(GenericElement $element, ViewRenderer $renderer): string + public function compile(ViewComponentElement $element): string { return << diff --git a/tests/Fixtures/Controllers/DocsController.php b/tests/Fixtures/Controllers/DocsController.php new file mode 100644 index 000000000..ca72ad74a --- /dev/null +++ b/tests/Fixtures/Controllers/DocsController.php @@ -0,0 +1,15 @@ +getAttribute('foo'); $bar = $element->getAttribute('bar'); diff --git a/tests/Fixtures/MyViewComponentWithInjection.php b/tests/Fixtures/MyViewComponentWithInjection.php index cb9a33172..fb253d054 100644 --- a/tests/Fixtures/MyViewComponentWithInjection.php +++ b/tests/Fixtures/MyViewComponentWithInjection.php @@ -4,9 +4,8 @@ namespace Tests\Tempest\Fixtures; -use Tempest\View\Elements\GenericElement; +use Tempest\View\Elements\ViewComponentElement; use Tempest\View\ViewComponent; -use Tempest\View\ViewRenderer; final readonly class MyViewComponentWithInjection implements ViewComponent { @@ -19,7 +18,7 @@ public function __construct() { } - public function render(GenericElement $element, ViewRenderer $renderer): string + public function compile(ViewComponentElement $element): string { return 'hi'; } diff --git a/tests/Fixtures/Views/ViewModel.php b/tests/Fixtures/Views/ViewModel.php index 56486b13d..d8539c7fc 100644 --- a/tests/Fixtures/Views/ViewModel.php +++ b/tests/Fixtures/Views/ViewModel.php @@ -14,7 +14,7 @@ final class ViewModel implements View public function __construct( public readonly string $name, ) { - $this->path = 'Views/withViewModel.php'; + $this->path = __DIR__ . '/withViewModel.php'; } public function currentDate(): string diff --git a/tests/Fixtures/Views/base.view.php b/tests/Fixtures/Views/base.view.php index 5260962ee..119767e24 100644 --- a/tests/Fixtures/Views/base.view.php +++ b/tests/Fixtures/Views/base.view.php @@ -2,12 +2,16 @@ use Tempest\View\GenericView; -/** @var GenericView $this */?> +/** + * @var GenericView $this + * @var string|null $title + */ +?> - <?= $this->title ?? 'Home' ?> + <?= $title ?? 'Home' ?> diff --git a/tests/Fixtures/Views/index.view.php b/tests/Fixtures/Views/index.view.php index 893f4d9ee..fa29f0728 100644 --- a/tests/Fixtures/Views/index.view.php +++ b/tests/Fixtures/Views/index.view.php @@ -1,7 +1,7 @@ - {{ $this->title }} + <?= $title ?? '' ?> diff --git a/tests/Fixtures/Views/raw-escaped.view.php b/tests/Fixtures/Views/raw-escaped.view.php new file mode 100644 index 000000000..c4db2afca --- /dev/null +++ b/tests/Fixtures/Views/raw-escaped.view.php @@ -0,0 +1,5 @@ +{{ $var }} + +{{ strtoupper($var) }} + +{!! $var !!} \ No newline at end of file diff --git a/tests/Fixtures/Views/rawAndEscaping.php b/tests/Fixtures/Views/rawAndEscaping.php deleted file mode 100644 index 3fea8edaf..000000000 --- a/tests/Fixtures/Views/rawAndEscaping.php +++ /dev/null @@ -1,8 +0,0 @@ - - -property ?> -raw('property') ?> \ No newline at end of file diff --git a/tests/Fixtures/Views/view-component-with-another-one-included-a.view.php b/tests/Fixtures/Views/view-component-with-another-one-included-a.view.php new file mode 100644 index 000000000..1f7630886 --- /dev/null +++ b/tests/Fixtures/Views/view-component-with-another-one-included-a.view.php @@ -0,0 +1,7 @@ + + +
+ +
+
+
\ No newline at end of file diff --git a/tests/Fixtures/Views/view-component-with-another-one-included-b.view.php b/tests/Fixtures/Views/view-component-with-another-one-included-b.view.php new file mode 100644 index 000000000..f4f1eda8f --- /dev/null +++ b/tests/Fixtures/Views/view-component-with-another-one-included-b.view.php @@ -0,0 +1,7 @@ + + hi + +
+ +
+
\ No newline at end of file diff --git a/tests/Fixtures/Views/view-component-with-use-import.view.php b/tests/Fixtures/Views/view-component-with-use-import.view.php new file mode 100644 index 000000000..d4ea333cf --- /dev/null +++ b/tests/Fixtures/Views/view-component-with-use-import.view.php @@ -0,0 +1,10 @@ + + + + + \ No newline at end of file diff --git a/tests/Fixtures/Views/view-defined-local-vars-a.view.php b/tests/Fixtures/Views/view-defined-local-vars-a.view.php index 53165ee3f..dd411ac5f 100644 --- a/tests/Fixtures/Views/view-defined-local-vars-a.view.php +++ b/tests/Fixtures/Views/view-defined-local-vars-a.view.php @@ -1,3 +1,3 @@ - var ?? 'nothing' ?> + \ No newline at end of file diff --git a/tests/Fixtures/Views/x-button-usage.view.php b/tests/Fixtures/Views/x-button-usage.view.php new file mode 100644 index 000000000..01d584cd9 --- /dev/null +++ b/tests/Fixtures/Views/x-button-usage.view.php @@ -0,0 +1,8 @@ + + +Read the docs \ No newline at end of file diff --git a/tests/Fixtures/Views/x-button.view.php b/tests/Fixtures/Views/x-button.view.php new file mode 100644 index 000000000..d66f76ed7 --- /dev/null +++ b/tests/Fixtures/Views/x-button.view.php @@ -0,0 +1,13 @@ + + + + + \ No newline at end of file diff --git a/tests/Fixtures/viewComponentWithVariable.view.php b/tests/Fixtures/viewComponentWithVariable.view.php index 06051336c..f6a8d30bc 100644 --- a/tests/Fixtures/viewComponentWithVariable.view.php +++ b/tests/Fixtures/viewComponentWithVariable.view.php @@ -1,5 +1,9 @@ + +
- variable ?> +
\ No newline at end of file diff --git a/tests/Integration/Http/GenericResponseSenderTest.php b/tests/Integration/Http/GenericResponseSenderTest.php index 4b5536c91..37935690a 100644 --- a/tests/Integration/Http/GenericResponseSenderTest.php +++ b/tests/Integration/Http/GenericResponseSenderTest.php @@ -96,7 +96,7 @@ public function test_view_body(): void ob_start(); $response = new Ok( - body: view('Views/overview.view.php')->data( + body: view(__DIR__ . '/../../Fixtures/Views/overview.view.php')->data( name: 'Brent', ), ); diff --git a/tests/Integration/View/ElementFactoryTest.php b/tests/Integration/View/ElementFactoryTest.php index af82a0d1f..e428dd0c7 100644 --- a/tests/Integration/View/ElementFactoryTest.php +++ b/tests/Integration/View/ElementFactoryTest.php @@ -5,16 +5,15 @@ namespace Tests\Tempest\Integration\View; use Masterminds\HTML5; -use PHPUnit\Framework\TestCase; -use function Tempest\view; use Tempest\View\Elements\ElementFactory; use Tempest\View\Elements\GenericElement; use Tempest\View\Elements\TextElement; +use Tests\Tempest\Integration\FrameworkIntegrationTestCase; /** * @internal */ -final class ElementFactoryTest extends TestCase +final class ElementFactoryTest extends FrameworkIntegrationTestCase { public function test_parental_relations(): void { @@ -33,9 +32,9 @@ public function test_parental_relations(): void $html5 = new HTML5(); $dom = $html5->loadHTML("
{$contents}
"); - $elementFactory = new ElementFactory(); + $elementFactory = $this->container->get(ElementFactory::class); - $a = $elementFactory->make(view(''), $dom->getElementById('tempest_render')->firstElementChild); + $a = $elementFactory->make($dom->getElementById('tempest_render')->firstElementChild); $this->assertInstanceOf(GenericElement::class, $a); $this->assertCount(1, $a->getChildren()); diff --git a/tests/Integration/View/TempestViewRendererTest.php b/tests/Integration/View/TempestViewRendererTest.php index f64a348f8..8528d5ec4 100644 --- a/tests/Integration/View/TempestViewRendererTest.php +++ b/tests/Integration/View/TempestViewRendererTest.php @@ -5,6 +5,8 @@ namespace Tests\Tempest\Integration\View; use function Tempest\view; +use Tempest\View\Exceptions\InvalidElement; +use Tempest\View\ViewCache; use Tests\Tempest\Integration\FrameworkIntegrationTestCase; /** @@ -12,6 +14,13 @@ */ final class TempestViewRendererTest extends FrameworkIntegrationTestCase { + protected function setUp(): void + { + parent::setUp(); + + $this->container->get(ViewCache::class)->clear(); + } + public function test_view_renderer(): void { $this->assertSame( @@ -20,8 +29,8 @@ public function test_view_renderer(): void ); $this->assertSame( - '

Hello

', - $this->render(view('

{{ $this->foo }}

')->data(foo: 'Hello')), + '

<span>Hello</span>

', + $this->render(view('

{{ $this->foo }}

')->data(foo: 'Hello')), ); $this->assertSame( @@ -30,8 +39,8 @@ public function test_view_renderer(): void ); $this->assertSame( - '

Hello

', - $this->render(view('

{{ $this->raw("foo") }}

')->data(foo: 'Hello')), + '

Hello

', + $this->render(view('

{!! $this->foo !!}

')->data(foo: 'Hello')), ); } @@ -233,4 +242,118 @@ public function test_pre(): void ), ); } + + public function test_use_statements_are_grouped(): void + { + $html = $this->render(''); + + $this->assertStringContainsString('/', $html); + } + + public function test_raw_and_escaped(): void + { + $html = $this->render(view(__DIR__ . '/../../Fixtures/Views/raw-escaped.view.php', var: '

hi

')); + + $this->assertStringEqualsStringIgnoringLineEndings(<<<'HTML' + <h1>hi</h1> + <H1>HI</H1> +

hi

+ HTML, $html); + } + + public function test_no_double_else_attributes(): void + { + $this->expectException(InvalidElement::class); + + $this->render( + <<<'HTML' +
+
+
+HTML, + ); + } + + public function test_else_must_be_after_if_or_elseif(): void + { + $this->render( + <<<'HTML' +
+
+HTML, + ); + + $this->render( + <<<'HTML' +
+
+
+HTML, + ); + + $this->expectException(InvalidElement::class); + + $this->render( + <<<'HTML' +
+HTML, + ); + } + + public function test_elseif_must_be_after_if_or_elseif(): void + { + $this->render( + <<<'HTML' +
+
+
+HTML, + ); + + $this->expectException(InvalidElement::class); + + $this->render( + <<<'HTML' +
+HTML, + ); + } + + public function test_forelse_must_be_before_foreach(): void + { + $this->render( + view(<<<'HTML' +
+
+HTML, foo: []), + ); + + $this->expectException(InvalidElement::class); + + $this->render( + <<<'HTML' +
+HTML, + ); + } + + public function test_no_double_forelse_attributes(): void + { + $this->render( + view(<<<'HTML' +
+
+HTML, foo: []), + ); + + $this->expectException(InvalidElement::class); + + $this->render( + view(<<<'HTML' +
+
+
+HTML, foo: []), + ); + } } diff --git a/tests/Integration/View/ViewComponentTest.php b/tests/Integration/View/ViewComponentTest.php index ed91fdc06..b4cfcc465 100644 --- a/tests/Integration/View/ViewComponentTest.php +++ b/tests/Integration/View/ViewComponentTest.php @@ -10,6 +10,7 @@ use Tempest\Validation\Rules\AlphaNumeric; use Tempest\Validation\Rules\Between; use function Tempest\view; +use Tempest\View\ViewCache; use Tests\Tempest\Fixtures\Views\Chapter; use Tests\Tempest\Fixtures\Views\DocsView; use Tests\Tempest\Integration\FrameworkIntegrationTestCase; @@ -19,6 +20,13 @@ */ final class ViewComponentTest extends FrameworkIntegrationTestCase { + protected function setUp(): void + { + parent::setUp(); + + $this->container->get(ViewCache::class)->clear(); + } + #[DataProvider('view_components')] public function test_view_components(string $component, string $rendered): void { @@ -52,7 +60,11 @@ public function test_nested_components(): void { $this->assertStringEqualsStringIgnoringLineEndings( expected: <<<'HTML' -
+
+
+
+ +
HTML, actual: $this->render(view( <<<'HTML' @@ -67,6 +79,32 @@ public function test_nested_components(): void ); } + public function test_component_with_anther_component_included(): void + { + $html = $this->render(''); + + $this->assertStringContainsStringIgnoringLineEndings(<<<'HTML' +hi + + +
+HTML, $html); + } + + public function test_component_with_anther_component_included_with_slot(): void + { + $html = $this->render('test'); + + $this->assertStringEqualsStringIgnoringLineEndings(<<<'HTML' +hi + + +
+ test +
+HTML, $html); + } + public function test_view_component_with_injected_view(): void { $between = new Between(min: 1, max: 10); @@ -123,36 +161,6 @@ public function test_component_with_foreach(): void ); } - public static function view_components(): Generator - { - yield [ - '', - '
', - ]; - - yield [ - 'body', - '
body
', - ]; - - yield [ - '

a

b

', - '

a

b

', - ]; - - yield [ - '
body
-
body
', - '
body
-
body
', - ]; - - yield [ - 'body', - '
body
', - ]; - } - public function test_anonymous_view_component(): void { $this->assertSame( @@ -174,7 +182,7 @@ public function test_with_header(): void public function test_with_passed_variable(): void { $rendered = $this->render( - view('')->data( + view('')->data( variable: 'test' ) ); @@ -207,7 +215,7 @@ public function test_with_passed_php_data(): void { $rendered = $this->render( view(<<' + HTML) ); @@ -252,13 +260,19 @@ public function test_with_passed_variable_within_loop(): void $this->assertStringEqualsStringIgnoringLineEndings( << - a -
- b
-
- c
- HTML, +
+ a
+ + + +
+ b
+ + + +
+ c
+ HTML, $rendered ); } @@ -267,10 +281,14 @@ public function test_inline_view_variables_passed_to_component(): void { $html = $this->render(view(__DIR__ . '/../../Fixtures/Views/view-defined-local-vars-b.view.php')); - $this->assertSame(<<assertStringEqualsStringIgnoringLineEndings(<<render(view(__DIR__ . '/../../Fixtures/Views/view-component-with-camelcase-attribute-b.view.php')); - $this->assertSame(<<assertStringContainsStringIgnoringLineEndings(<<render(view(__DIR__ . '/../../Fixtures/Views/x-button-usage.view.php')); + + $this->assertStringContainsString('/docs/', $html); + } + + public static function view_components(): Generator + { + yield [ + '', + '
', + ]; + + yield [ + 'body', + '
body
', + ]; + + yield [ + '

a

b

', + '

a

b

', + ]; + + yield [ + '
body
+
body
', + '
body
+
body
', + ]; + + yield [ + 'body', + '
body
', + ]; + } } diff --git a/tests/Integration/View/ViewTest.php b/tests/Integration/View/ViewTest.php index 43b6f1986..f77dc1cdc 100644 --- a/tests/Integration/View/ViewTest.php +++ b/tests/Integration/View/ViewTest.php @@ -18,17 +18,19 @@ final class ViewTest extends FrameworkIntegrationTestCase { public function test_render(): void { - $view = view('Views/overview.view.php')->data(name: 'Brent'); + $view = view(__DIR__ . '/../../Fixtures/Views/overview.view.php')->data(name: 'Brent'); $html = $this->render($view); - $expected = << - Hello Brent! - - HTML; + $this->assertStringContainsString( + 'Hello Brent!', + $html + ); - $this->assertStringContainsStringIgnoringLineEndings($expected, $html); + $this->assertStringContainsString( + '', + $html + ); } public function test_render_with_view_model(): void @@ -44,20 +46,6 @@ public function test_render_with_view_model(): void $this->assertEquals($expected, $html); } - public function test_raw_and_escaping(): void - { - $html = $this->render(view('Views/rawAndEscaping.php')->data( - property: '

hi

', - )); - - $expected = <<hi - HTML; - - $this->assertStringEqualsStringIgnoringLineEndings(trim($expected), trim($html)); - } - public function test_custom_view_with_response_data(): void { $this->http