From d6635dcb2301e9e8d3c1343cbaee660af588825a Mon Sep 17 00:00:00 2001 From: John Koster Date: Fri, 31 Jan 2025 09:30:28 -0600 Subject: [PATCH 01/12] Add support for cache attribute --- src/Cache/CacheAttributeParser.php | 118 +++++++++++++ src/Cache/CacheProperties.php | 13 ++ src/Compiler/ComponentState.php | 3 + .../Concerns/AppliesCompilerParams.php | 12 ++ src/Compiler/Concerns/CompilesCache.php | 120 ++++++++++++++ .../Concerns/CompilesForwardedAttributes.php | 6 + src/Compiler/TemplateCompiler.php | 13 +- tests/Cache/CacheAttributeTest.php | 144 ++++++++++++++++ tests/Cache/CacheParserTest.php | 156 ++++++++++++++++++ .../cache_attribute/basic.blade.php | 4 + 10 files changed, 588 insertions(+), 1 deletion(-) create mode 100644 src/Cache/CacheAttributeParser.php create mode 100644 src/Cache/CacheProperties.php create mode 100644 src/Compiler/Concerns/CompilesCache.php create mode 100644 tests/Cache/CacheAttributeTest.php create mode 100644 tests/Cache/CacheParserTest.php create mode 100644 tests/resources/components/cache_attribute/basic.blade.php diff --git a/src/Cache/CacheAttributeParser.php b/src/Cache/CacheAttributeParser.php new file mode 100644 index 0000000..a459402 --- /dev/null +++ b/src/Cache/CacheAttributeParser.php @@ -0,0 +1,118 @@ +getDefaultDriver(); + } + + public static function parseCacheString(string $cache): CacheProperties + { + if (! Str::startsWith($cache, 'cache.') && $cache !== 'cache') { + $cache = 'cache.'.$cache; + } + + $parts = explode('.', $cache); + + $extraArgs = []; + $store = static::getStore($parts); + $duration = static::getDuration($parts); + + if (is_array($duration)) { + $extraArgs = $duration[1]; + $duration = $duration[0]; + } + + return new CacheProperties( + $duration, + $store, + $extraArgs + ); + } +} diff --git a/src/Cache/CacheProperties.php b/src/Cache/CacheProperties.php new file mode 100644 index 0000000..ed46a7e --- /dev/null +++ b/src/Cache/CacheProperties.php @@ -0,0 +1,13 @@ +isCacheParam($param)) { + return true; + } + if (in_array($param->type, $this->invalidCompilerParamTypes, true)) { return false; } @@ -39,6 +43,14 @@ protected function applyCompilerParameters(ComponentState $component, array $com return; } + $cacheParam = collect($compilerParams) + ->where(fn (ParameterNode $param) => $this->isCacheParam($param)) + ->first(); + + if ($cacheParam) { + $this->applyCacheParam($component, $cacheParam); + } + $compilerParams = collect($compilerParams) ->mapWithKeys(function (ParameterNode $param) { if (! $this->isValidCompilerParam($param)) { diff --git a/src/Compiler/Concerns/CompilesCache.php b/src/Compiler/Concerns/CompilesCache.php new file mode 100644 index 0000000..9836353 --- /dev/null +++ b/src/Compiler/Concerns/CompilesCache.php @@ -0,0 +1,120 @@ +materializedName === '#cache' || Str::startsWith($param->materializedName, '#cache.'); + } + + protected function applyCacheParam(ComponentState $component, ParameterNode $cacheParam): void + { + $cacheString = $cacheParam->materializedName; + + if (Str::startsWith($cacheString, '#')) { + $cacheString = ltrim($cacheString, '#'); + } + + $cacheProperties = CacheAttributeParser::parseCacheString($cacheString); + + if (is_array($cacheProperties->duration)) { + $now = now()->clone(); + $expires = $now->clone() + ->addYears($cacheProperties->duration[0]) + ->addMonths($cacheProperties->duration[1]) + ->addWeeks($cacheProperties->duration[2]) + ->addDays($cacheProperties->duration[3]) + ->addHours($cacheProperties->duration[4]) + ->addMinutes($cacheProperties->duration[5]) + ->addSeconds($cacheProperties->duration[6]); + + $cacheProperties->duration = $now->diffInSeconds($expires); + } + + // TODO: INteroplated variables test. + if ($cacheParam->type == ParameterType::DynamicVariable) { + $cacheProperties->key = $cacheParam->value; + } else { + $cacheProperties->key = $cacheParam->valueNode->content; + } + + $component->cacheProperties = $cacheProperties; + } + + protected function compileCache(string $compiledComponent): string + { + if ($this->activeComponent->cacheProperties->duration === 'flexible') { + $cacheStub = <<<'PHP' +store('#store#')->flexible($__cacheKeyVarSuffix, [$fresh, $stale], function () use ($__cacheTmpVarsVarSuffix) { +extract($__cacheTmpVarsVarSuffix); +ob_start(); +?>#compiled# +PHP; + + $cacheStub = Str::swap([ + '$fresh' => $this->activeComponent->cacheProperties->args[0], + '$stale' => $this->activeComponent->cacheProperties->args[1], + ], $cacheStub); + + } else { + $cacheStub = <<<'PHP' +store('#store#')->has($__cacheKeyVarSuffix)) { + echo cache()->store('#store#')->get($__cacheKeyVarSuffix); + unset($__cacheKeyVarSuffix); +} else { ob_start(); +?>#compiled# +PHP; + + if ($this->activeComponent->cacheProperties->duration === 'forever') { + $cacheMethod = <<<'PHP' +cache()->store('#store#')->forever($__cacheKeyVarSuffix, $__cacheResultVarSuffix); +PHP; + } else { + $cacheMethod = <<<'PHP' +cache()->store('#store#')->put($__cacheKeyVarSuffix, $__cacheResultVarSuffix, '#ttl#'); +PHP; + + $cacheMethod = Str::swap([ + "'#ttl#'" => $this->activeComponent->cacheProperties->duration, + ], $cacheMethod); + } + + $cacheStub = Str::swap([ + '#cacheMethod#' => $cacheMethod, + ], $cacheStub); + } + + return Str::swap([ + 'VarSuffix' => Utils::makeRandomString(), + '#store#' => $this->activeComponent->cacheProperties->store, + "'#key#'" => $this->activeComponent->cacheProperties->key, + '#compiled#' => $compiledComponent, + ], $cacheStub); + } +} diff --git a/src/Compiler/Concerns/CompilesForwardedAttributes.php b/src/Compiler/Concerns/CompilesForwardedAttributes.php index b30f44e..4eb37cb 100644 --- a/src/Compiler/Concerns/CompilesForwardedAttributes.php +++ b/src/Compiler/Concerns/CompilesForwardedAttributes.php @@ -16,6 +16,12 @@ protected function filterParameters(ComponentNode $node): array $paramsToKeep = []; foreach ($node->parameters as $param) { + if ($this->isCacheParam($param)) { + $compilerParams[] = $param; + + continue; + } + if (Str::startsWith($param->name, '##')) { $param->name = mb_substr($param->name, 1); $paramsToKeep[] = $param; diff --git a/src/Compiler/TemplateCompiler.php b/src/Compiler/TemplateCompiler.php index da8bd08..e91159a 100644 --- a/src/Compiler/TemplateCompiler.php +++ b/src/Compiler/TemplateCompiler.php @@ -15,6 +15,7 @@ use Stillat\BladeParser\Parser\DocumentParser; use Stillat\Dagger\Compiler\Concerns\AppliesCompilerParams; use Stillat\Dagger\Compiler\Concerns\CompilesBasicComponents; +use Stillat\Dagger\Compiler\Concerns\CompilesCache; use Stillat\Dagger\Compiler\Concerns\CompilesCompilerDirectives; use Stillat\Dagger\Compiler\Concerns\CompilesComponentDetails; use Stillat\Dagger\Compiler\Concerns\CompilesDynamicComponents; @@ -40,6 +41,7 @@ final class TemplateCompiler use AppliesCompilerParams, CompilesBasicComponents, + CompilesCache, CompilesCompilerDirectives, CompilesComponentDetails, CompilesDynamicComponents, @@ -564,7 +566,7 @@ protected function compileNodes(array $nodes): string $compiledComponentTemplate = Str::swap($swapVars, $compiledComponentTemplate); $compiledComponentTemplate = $this->compileExceptions($compiledComponentTemplate); - $compiled .= $this->storeComponentBlock($compiledComponentTemplate); + $compiled .= $this->finalizeCompiledComponent($compiledComponentTemplate); $this->stopCompilingComponent(); } @@ -578,6 +580,15 @@ protected function compileNodes(array $nodes): string return $compiled; } + protected function finalizeCompiledComponent(string $compiled): string + { + if ($this->activeComponent->cacheProperties != null) { + $compiled = $this->compileCache($compiled); + } + + return $this->storeComponentBlock($compiled); + } + public function cleanup(): void { $this->componentParser->getComponentCache()->clear(); diff --git a/tests/Cache/CacheAttributeTest.php b/tests/Cache/CacheAttributeTest.php new file mode 100644 index 0000000..2ed9a4c --- /dev/null +++ b/tests/Cache/CacheAttributeTest.php @@ -0,0 +1,144 @@ + +@endfor +BLADE; + + $expected = <<<'EXPECTED' +Title: The Title +Var: 1 + +Title: The Title +Var: 1 + +Title: The Title +Var: 1 + +Title: The Title +Var: 1 + +Title: The Title +Var: 1 +EXPECTED; + + $this->assertSame($expected, $this->render($template)); + $this->assertSame($expected, $this->render($template)); + $this->assertSame($expected, $this->render($template)); +}); + +test('forever cache attribute', function () { + $template = <<<'BLADE' +@for ($i = 0; $i < 5; $i++) + +@endfor +BLADE; + + $expected = <<<'EXPECTED' +Title: The Title2 +Var: 1 + +Title: The Title2 +Var: 1 + +Title: The Title2 +Var: 1 + +Title: The Title2 +Var: 1 + +Title: The Title2 +Var: 1 +EXPECTED; + + $this->assertSame($expected, $this->render($template)); + $this->assertSame($expected, $this->render($template)); +}); + +test('flexible cache attribute', function () { + $template = <<<'BLADE' +@for ($i = 0; $i < 5; $i++) + +@endfor +BLADE; + + $expected = <<<'EXPECTED' +Title: The Title3 +Var: 1 + +Title: The Title3 +Var: 1 + +Title: The Title3 +Var: 1 + +Title: The Title3 +Var: 1 + +Title: The Title3 +Var: 1 +EXPECTED; + + $compiled = $this->compile($template); + + $this->assertStringContainsString( + "echo cache()->store('array')->flexible(", + $compiled + ); + + $this->assertStringContainsString( + ', [10, 20], function ()', + $compiled, + ); + + $this->assertSame($expected, $this->render($template)); + $this->assertSame($expected, $this->render($template)); + $this->assertSame($expected, $this->render($template)); +}); + +test('dynamic cache keys', function () { + $template = <<<'BLADE' +@for ($i = 0; $i < 5; $i++) + +@endfor +BLADE; + + $expected = <<<'EXPECTED' +Title: The Title4 +Var: 1 + +Title: The Title4 +Var: 2 + +Title: The Title4 +Var: 3 + +Title: The Title4 +Var: 4 + +Title: The Title4 +Var: 5 +EXPECTED; + + $this->assertSame($expected, $this->render($template)); + $this->assertSame($expected, $this->render($template)); + $this->assertSame($expected, $this->render($template)); +}); diff --git a/tests/Cache/CacheParserTest.php b/tests/Cache/CacheParserTest.php new file mode 100644 index 0000000..d7b07e5 --- /dev/null +++ b/tests/Cache/CacheParserTest.php @@ -0,0 +1,156 @@ +parseCacheString($cacheString); + + expect($parseResults->duration)->toBe($expectedDuration) + ->and($parseResults->store)->toBe($expectedStore) + ->and($parseResults->args)->toBe($expectedExtraParams); +})->with([ + ['cache', ['forever', 'array', []]], + ['cache.forever', ['forever', 'array', []]], + ['cache.forever.array', ['forever', 'array', []]], + ['forever.array1', ['forever', 'array1', []]], + ['forever.array1', ['forever', 'array1', []]], + ['flexible:5:10', ['flexible', 'array', ['5', '10']]], + ['cache.flexible:5:10', ['flexible', 'array', ['5', '10']]], + ['cache.flexible:15:150', ['flexible', 'array', ['15', '150']]], + ['cache.100', ['100', 'array', []]], + ['100', ['100', 'array', []]], + ['100.file', ['100', 'file', []]], + ['cache.100.file', ['100', 'file', []]], + ['42', ['42', 'array', []]], + ['42.file', ['42', 'file', []]], + ['cache.42.file', ['42', 'file', []]], + [ + 'cache.1y.file', + [ + [1, 0, 0, 0, 0, 0, 0], + 'file', + [], + ], + ], + [ + 'cache.2mo.file', + [ + [0, 2, 0, 0, 0, 0, 0], + 'file', + [], + ], + ], + [ + 'cache.3w.file', + [ + [0, 0, 3, 0, 0, 0, 0], + 'file', + [], + ], + ], + [ + 'cache.4d.file', + [ + [0, 0, 0, 4, 0, 0, 0], + 'file', + [], + ], + ], + [ + 'cache.5h.file', + [ + [0, 0, 0, 0, 5, 0, 0], + 'file', + [], + ], + ], + [ + 'cache.6m.file', + [ + [0, 0, 0, 0, 0, 6, 0], + 'file', + [], + ], + ], + [ + 'cache.7s.file', + [ + [0, 0, 0, 0, 0, 0, 7], + 'file', + [], + ], + ], + [ + 'cache.1y2mo.file', + [ + [1, 2, 0, 0, 0, 0, 0], + 'file', + [], + ], + ], + [ + 'cache.1y2mo3w.file', + [ + [1, 2, 3, 0, 0, 0, 0], + 'file', + [], + ], + ], + [ + 'cache.1y2mo3w4d.file', + [ + [1, 2, 3, 4, 0, 0, 0], + 'file', + [], + ], + ], + [ + 'cache.1y2mo3w4d5h.file', + [ + [1, 2, 3, 4, 5, 0, 0], + 'file', + [], + ], + ], + [ + 'cache.1y2mo3w4d5h6m.file', + [ + [1, 2, 3, 4, 5, 6, 0], + 'file', + [], + ], + ], + [ + 'cache.1y2mo3w4d5h6m7s.file', + [ + [1, 2, 3, 4, 5, 6, 7], + 'file', + [], + ], + ], + [ + 'cache.1d2h3m4s.file', + [ + [0, 0, 0, 1, 2, 3, 4], + 'file', + [], + ], + ], + [ + '1y2mo3w4d5h6m7s.database', + [ + [1, 2, 3, 4, 5, 6, 7], + 'database', + [], + ], + ], +]); diff --git a/tests/resources/components/cache_attribute/basic.blade.php b/tests/resources/components/cache_attribute/basic.blade.php new file mode 100644 index 0000000..d8e6ce2 --- /dev/null +++ b/tests/resources/components/cache_attribute/basic.blade.php @@ -0,0 +1,4 @@ +@props(['title']) + +Title: {{ $title }} +Var: {{ \Stillat\Dagger\Tests\StaticTestHelpers::counter() }} \ No newline at end of file From 6bd98b873af8c7f3893b1bac1071415a529f9480 Mon Sep 17 00:00:00 2001 From: John Koster Date: Sat, 8 Feb 2025 12:02:30 -0600 Subject: [PATCH 02/12] Circular component support --- .../Concerns/CompilesComponentDetails.php | 16 +++ .../Concerns/CompilesDynamicComponents.php | 22 +++- src/Compiler/ParameterFactory.php | 3 +- src/Compiler/TemplateCompiler.php | 12 ++ tests/Compiler/CircularComponentsTest.php | 117 ++++++++++++++++++ .../components/thread/comment.blade.php | 9 ++ .../components/thread/index.blade.php | 7 ++ 7 files changed, 182 insertions(+), 4 deletions(-) create mode 100644 tests/Compiler/CircularComponentsTest.php create mode 100644 tests/resources/components/thread/comment.blade.php create mode 100644 tests/resources/components/thread/index.blade.php diff --git a/src/Compiler/Concerns/CompilesComponentDetails.php b/src/Compiler/Concerns/CompilesComponentDetails.php index 730d6ba..348c4e6 100644 --- a/src/Compiler/Concerns/CompilesComponentDetails.php +++ b/src/Compiler/Concerns/CompilesComponentDetails.php @@ -33,6 +33,22 @@ protected function assertPrefixIsNotLaravelPrefix(string $componentPrefix): void throw new InvalidArgumentException('Cannot register [x] as a component prefix.'); } + protected function getComponentHash(ComponentNode $node): string + { + $value = $node->content; + + if ($node->isClosedBy != null) { + $value .= $node->innerDocumentContent; + } + + return md5($value); + } + + protected function getComponentName(ComponentNode $node): string + { + return "{$node->componentPrefix}:{$node->tagName}"; + } + /** * @throws InvalidArgumentException */ diff --git a/src/Compiler/Concerns/CompilesDynamicComponents.php b/src/Compiler/Concerns/CompilesDynamicComponents.php index 2f5b6c9..930e05a 100644 --- a/src/Compiler/Concerns/CompilesDynamicComponents.php +++ b/src/Compiler/Concerns/CompilesDynamicComponents.php @@ -5,6 +5,7 @@ use Illuminate\Support\Str; use Stillat\BladeParser\Nodes\Components\ComponentNode; use Stillat\BladeParser\Nodes\Components\ParameterNode; +use Stillat\Dagger\Compiler\ParameterFactory; use Stillat\Dagger\Facades\SourceMapper; use Stillat\Dagger\Support\Utils; @@ -75,9 +76,24 @@ public function compileDynamicComponent(array $componentDetails, string $compone return $dynamicComponentPath; } - protected function compileDynamicComponentScaffolding(ComponentNode $component, string $viewPath): string + protected function compileCircularComponent(ComponentNode $node, string $currentViewPath): string { - $dynamicComponentName = Utils::makeRandomString(); + $circularComponent = clone $node; + $circularComponent->name = 'delegate-component'; + $circularComponent->parameters[] = ParameterFactory::parameterFromText('component="'.$node->tagName.'"'); + + return $this->compileDynamicComponentScaffolding( + $circularComponent, + $currentViewPath, + $this->getComponentHash($node) + ); + } + + protected function compileDynamicComponentScaffolding(ComponentNode $component, string $viewPath, ?string $dynamicComponentName = null): string + { + if (! $dynamicComponentName) { + $dynamicComponentName = Utils::makeRandomString(); + } $dynamicComponent = clone $component; $dynamicComponent->parameters = collect($dynamicComponent->parameters) @@ -97,7 +113,7 @@ protected function compileDynamicComponentScaffolding(ComponentNode $component, ); } - $contentDelimiter = '[DYNAMIC::COMPONENT::CONTENT'.Utils::makeRandomString().']'; + $contentDelimiter = '[DYNAMIC::COMPONENT::CONTENT'.md5($dynamicComponentName).']'; // Ensure line numbers remain consistent. $dynamicTemplate = str_repeat($this->newlineStyle, ($component->position?->startLine ?? 1) - 1); diff --git a/src/Compiler/ParameterFactory.php b/src/Compiler/ParameterFactory.php index fecaaeb..b9d06f8 100644 --- a/src/Compiler/ParameterFactory.php +++ b/src/Compiler/ParameterFactory.php @@ -2,10 +2,11 @@ namespace Stillat\Dagger\Compiler; +use Stillat\BladeParser\Nodes\Components\ParameterFactory as BladeParameterFactory; use Stillat\BladeParser\Nodes\Components\ParameterNode; use Stillat\BladeParser\Nodes\Components\ParameterType; -class ParameterFactory +class ParameterFactory extends BladeParameterFactory { public static function makeVariableReference(string $variableName, string $value): ParameterNode { diff --git a/src/Compiler/TemplateCompiler.php b/src/Compiler/TemplateCompiler.php index da8bd08..0683852 100644 --- a/src/Compiler/TemplateCompiler.php +++ b/src/Compiler/TemplateCompiler.php @@ -70,6 +70,8 @@ final class TemplateCompiler protected array $componentStack = []; + protected array $activeComponentNames = []; + protected array $componentPath = []; protected BladeCompiler $compiler; @@ -253,6 +255,7 @@ protected function startCompilingComponent(ComponentState $state, array $compile $this->applyCompilerParameters($state, $compilerParams); $this->componentStack[] = $state; + $this->activeComponentNames[] = $this->getComponentName($state->node); $this->activeComponent = $state; $this->componentPath[] = $state->compilerId; @@ -262,6 +265,7 @@ protected function stopCompilingComponent(): void { array_pop($this->componentStack); array_pop($this->componentPath); + array_pop($this->activeComponentNames); if (count($this->componentStack) > 0) { $this->activeComponent = $this->componentStack[array_key_last($this->componentStack)]; @@ -377,6 +381,14 @@ protected function compileNodes(array $nodes): string return $compiled; } + $currentComponentName = $this->getComponentName($node); + + if (in_array($currentComponentName, $this->activeComponentNames)) { + $compiled .= $this->compileCircularComponent($node, $currentViewPath ?? ''); + + continue; + } + $varSuffix = Utils::makeRandomString(); $view = $this->manifest->withoutTracing(fn () => $this->resolveView($node)); $sourcePath = $view->getPath(); diff --git a/tests/Compiler/CircularComponentsTest.php b/tests/Compiler/CircularComponentsTest.php new file mode 100644 index 0000000..0db5e7e --- /dev/null +++ b/tests/Compiler/CircularComponentsTest.php @@ -0,0 +1,117 @@ + 'Message 1'], + ['message' => 'Message 2'], + [ + 'message' => 'Message 3', + 'comments' => [ + ['message' => 'Message 3.1'], + [ + 'message' => 'Message 3.2', + 'comments' => [ + ['message' => 'Message 3.2.1'], + ['message' => 'Message 3.2.2'], + [ + 'message' => 'Message 3.2.3', + 'comments' => [ + ['message' => 'Message 3.2.3.1'], + ['message' => 'Message 3.2.3.2'], + ['message' => 'Message 3.2.3.3'], + ], + ], + ], + ], + ['message' => 'Message 3.3'], + ], + ], + ]; + + $template = <<<'BLADE' + +BLADE; + + $expected = <<<'EXPECTED' +
    + + +
  • + Message 1 + +
  • + +
  • + Message 2 + +
  • + +
  • + Message 3 + + + +
      + + +
    • + Message 3.1 + +
    • + +
    • + Message 3.2 + + + +
        + + +
      • + Message 3.2.1 + +
      • + +
      • + Message 3.2.2 + +
      • + +
      • + Message 3.2.3 + + + +
          + + +
        • + Message 3.2.3.1 + +
        • + +
        • + Message 3.2.3.2 + +
        • + +
        • + Message 3.2.3.3 + +
    • + +
    • + Message 3.3 + +
+EXPECTED; + + $this->assertSame( + $expected, + $this->render($template, ['comments' => $comments]) + ); +}); diff --git a/tests/resources/components/thread/comment.blade.php b/tests/resources/components/thread/comment.blade.php new file mode 100644 index 0000000..5f7e65c --- /dev/null +++ b/tests/resources/components/thread/comment.blade.php @@ -0,0 +1,9 @@ +@props(['comment']) + +
  • + {{ $comment['message'] }} + + @if (isset($comment['comments']) && count($comment['comments']) > 0) + + @endif +
  • \ No newline at end of file diff --git a/tests/resources/components/thread/index.blade.php b/tests/resources/components/thread/index.blade.php new file mode 100644 index 0000000..1ba5b37 --- /dev/null +++ b/tests/resources/components/thread/index.blade.php @@ -0,0 +1,7 @@ +@props(['comments']) + +
      + @foreach ($comments as $comment) + + @endforeach +
    \ No newline at end of file From 8350f41455c081b0959ba3aa19ef36330fa348e1 Mon Sep 17 00:00:00 2001 From: John Koster Date: Sat, 8 Feb 2025 12:04:57 -0600 Subject: [PATCH 03/12] Update composer.json --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index f44ddbe..a68b6c1 100644 --- a/composer.json +++ b/composer.json @@ -15,7 +15,7 @@ } }, "require": { - "laravel/framework": "^11.9", + "laravel/framework": "^11.23", "stillat/blade-parser": "^1.10.3", "nikic/php-parser": "^5", "symfony/var-exporter": "^6.0" From a2fb3b614e88d8181bf39fece9a00b1c60d9d38a Mon Sep 17 00:00:00 2001 From: John Koster Date: Sat, 8 Feb 2025 12:11:45 -0600 Subject: [PATCH 04/12] Update CacheAttributeTest.php --- tests/Cache/CacheAttributeTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Cache/CacheAttributeTest.php b/tests/Cache/CacheAttributeTest.php index 2ed9a4c..509d07c 100644 --- a/tests/Cache/CacheAttributeTest.php +++ b/tests/Cache/CacheAttributeTest.php @@ -109,7 +109,7 @@ $this->assertSame($expected, $this->render($template)); $this->assertSame($expected, $this->render($template)); $this->assertSame($expected, $this->render($template)); -}); +})->skip(); test('dynamic cache keys', function () { $template = <<<'BLADE' From ca301714c9de73ad6b73ef31b9802a8871883bdf Mon Sep 17 00:00:00 2001 From: John Koster Date: Sat, 8 Feb 2025 12:13:19 -0600 Subject: [PATCH 05/12] Update composer.json --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index a68b6c1..16fb4d4 100644 --- a/composer.json +++ b/composer.json @@ -15,7 +15,7 @@ } }, "require": { - "laravel/framework": "^11.23", + "laravel/framework": "^11.24", "stillat/blade-parser": "^1.10.3", "nikic/php-parser": "^5", "symfony/var-exporter": "^6.0" From e02e010a42812cb4ab246c57a80339ca94ad5fe7 Mon Sep 17 00:00:00 2001 From: John Koster Date: Sat, 8 Feb 2025 12:19:15 -0600 Subject: [PATCH 06/12] Test/dep updates. --- composer.json | 4 ++-- tests/Cache/CacheAttributeTest.php | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/composer.json b/composer.json index 16fb4d4..746f5f7 100644 --- a/composer.json +++ b/composer.json @@ -15,13 +15,13 @@ } }, "require": { - "laravel/framework": "^11.24", + "laravel/framework": "^11.23", "stillat/blade-parser": "^1.10.3", "nikic/php-parser": "^5", "symfony/var-exporter": "^6.0" }, "require-dev": { - "orchestra/testbench": "^9.0", + "orchestra/testbench": "^9.2", "brianium/paratest": "*", "pestphp/pest": "^2", "laravel/pint": "^1.13", diff --git a/tests/Cache/CacheAttributeTest.php b/tests/Cache/CacheAttributeTest.php index 509d07c..2ed9a4c 100644 --- a/tests/Cache/CacheAttributeTest.php +++ b/tests/Cache/CacheAttributeTest.php @@ -109,7 +109,7 @@ $this->assertSame($expected, $this->render($template)); $this->assertSame($expected, $this->render($template)); $this->assertSame($expected, $this->render($template)); -})->skip(); +}); test('dynamic cache keys', function () { $template = <<<'BLADE' From b96a3f0db45e46a676444b17ddb8b07da957ca97 Mon Sep 17 00:00:00 2001 From: John Koster Date: Sat, 8 Feb 2025 13:08:52 -0600 Subject: [PATCH 07/12] Some docs --- README.md | 95 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) diff --git a/README.md b/README.md index ca6d866..db95df4 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,10 @@ The main visual difference when working with Dagger components is the use of the - [Shorthand Validation Rules](#shorthand-validation-rules) - [Compiler Attributes](#compiler-attributes) - [Escaping Compiler Attributes](#escaping-compiler-attributes) +- [Caching Components](#caching-components) + - [Dynamic Cache Keys](#dynamic-cache-keys) + - [Specifying the Cache Store](#specifying-the-cache-store) + - [Stale While Revalidate/Flexible Caching](#stale-while-revalidate-flexible-cache) - [Component Name](#component-name) - [Component Depth](#component-depth) - [Attribute Forwarding](#attribute-forwarding) @@ -658,6 +662,97 @@ In general, you should avoid using props or attributes beginning with `#` as the - `#cache` - `#precompile` +## Caching Components + +You may cache the output of any Dagger component using the `#cache` compiler attribute. This attribute utilizes Laravel's [Cache](https://laravel.com/docs/cache) feature, and provides ways to customize cache keys, expirations, and the cache store. + +For example, imagine we had a report component that we'd like to cache: + +```blade + + +@php + // Some expensive report logic. +@endphp +``` + +Instead of manually capturing output, or adding caching in other locations, we can simply cache the output of our component call like so: + +```blade + +``` + +Now, the output of the component will be cached forever using the `the-cache-key` string as the cache key. + +We may also specify a different time-to-live by specifying the number of seconds the cached output is valid: + +```blade + +``` + +You may also use a shorthand notation to calculate the time-to-live in seconds. For example, if we'd like to have the cache expire ten minutes from the time the component was first rendered we could use the value `10m`: + +```blade + +``` + +Alternatively, we could also have the cache expire in 1 hour, 15 minutes, and 32 seconds: + +```blade + +``` + +The total number of seconds is calculated dynamically by adding the desired "date parts" to the current time and *then* calculating the number of seconds to use. + +The following table lists the possible suffixes that may be used: + +| Suffix | Description | Example | +|---|---|---| +| y | Year | 1y | +| mo | Month | 1mo | +| w | Week | 1w | +| d | Day | 2d | +| h | Hour | 1h | +| m | Minute | 30m | +| s | Seconds | 15s | + +### Dynamic Cache Keys + +You may create dynamic cache keys by prefixing the `#cache` attribute with the `:` character: + +```blade + +``` + +### Specifying the Cache Store + +You may use a specific cache store by providing the desired cache store's name as the final modifier to the `#cache` attribute. + +The following examples would cache the output for 30 seconds on different cache stores: + +```blade + + + +``` + +### Stale While Revalidate (Flexible Cache) + +You may leverage Laravel's [stale-while-revalidate pattern implementation](https://laravel.com/docs/11.x/cache#swr) using the `flexible` cache modifier. This modifier accepts two values: the number of seconds the cache is considered fresh, and the second value determines how long the cached contents can be served as stale before recalculation is necessary. + +```blade + +``` + ## Component Name You may access the name of the current component through the `name` property on the component instance: From 36c774eaaa8ba74482e7ab702209a98052a5ef96 Mon Sep 17 00:00:00 2001 From: John Koster Date: Sat, 8 Feb 2025 13:19:51 -0600 Subject: [PATCH 08/12] Some extra coverage --- src/Compiler/Concerns/CompilesCache.php | 1 - tests/Cache/CacheAttributeTest.php | 32 +++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/src/Compiler/Concerns/CompilesCache.php b/src/Compiler/Concerns/CompilesCache.php index 9836353..db02dd3 100644 --- a/src/Compiler/Concerns/CompilesCache.php +++ b/src/Compiler/Concerns/CompilesCache.php @@ -40,7 +40,6 @@ protected function applyCacheParam(ComponentState $component, ParameterNode $cac $cacheProperties->duration = $now->diffInSeconds($expires); } - // TODO: INteroplated variables test. if ($cacheParam->type == ParameterType::DynamicVariable) { $cacheProperties->key = $cacheParam->value; } else { diff --git a/tests/Cache/CacheAttributeTest.php b/tests/Cache/CacheAttributeTest.php index 2ed9a4c..796fc11 100644 --- a/tests/Cache/CacheAttributeTest.php +++ b/tests/Cache/CacheAttributeTest.php @@ -142,3 +142,35 @@ $this->assertSame($expected, $this->render($template)); $this->assertSame($expected, $this->render($template)); }); + +test('dynamic cache keys using interpolation', function () { + $template = <<<'BLADE' +@for ($i = 0; $i < 5; $i++) + +@endfor +BLADE; + + $expected = <<<'EXPECTED' +Title: The Title4 +Var: 1 + +Title: The Title4 +Var: 2 + +Title: The Title4 +Var: 3 + +Title: The Title4 +Var: 4 + +Title: The Title4 +Var: 5 +EXPECTED; + + $this->assertSame($expected, $this->render($template)); + $this->assertSame($expected, $this->render($template)); + $this->assertSame($expected, $this->render($template)); +}); From 586575a959f3c5ce2c5902c7a943a1b3aa88ce80 Mon Sep 17 00:00:00 2001 From: John Koster Date: Sat, 8 Feb 2025 13:21:57 -0600 Subject: [PATCH 09/12] Update CHANGELOG.md --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 09b2e4a..0571fff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## Unreleased + +- Adds a `#cache` compiler attribute, which may be used to cache the results of any Dagger component +- Bumps the minimum Laravel version to `11.23`, for `Cache::flexible` support + ## [v1.0.6](https://github.com/Stillat/dagger/compare/v1.0.5...v1.0.6) - 2025-01-31 - Corrects an issue where Blade stack compilation results in array index errors From e39d53cbb7903c452269514315ba145112efd5e7 Mon Sep 17 00:00:00 2001 From: John Koster Date: Sat, 8 Feb 2025 13:23:30 -0600 Subject: [PATCH 10/12] Update CHANGELOG.md --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 09b2e4a..f3eabdb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## Unreleased + +- Adds compiler support for circular component references, like nested comment threads + ## [v1.0.6](https://github.com/Stillat/dagger/compare/v1.0.5...v1.0.6) - 2025-01-31 - Corrects an issue where Blade stack compilation results in array index errors From 37db81aaad25e8b489fc893991f34ee0f6f97989 Mon Sep 17 00:00:00 2001 From: John Koster Date: Sat, 8 Feb 2025 14:16:00 -0600 Subject: [PATCH 11/12] =?UTF-8?q?=F0=9F=A5=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 4 ---- 1 file changed, 4 deletions(-) diff --git a/README.md b/README.md index ca6d866..9d70a2b 100644 --- a/README.md +++ b/README.md @@ -108,10 +108,6 @@ Dagger components are also interopable with Blade components, and will add thems This is due to the View Manifest. The Dagger compiler and runtime will store which component files were used to create the final output in a JSON file, which is later used for cache-invalidation. The Dagger compiler inlines component templates, which prevents typical file-based cache invalidation from working; the View Manifest solves that problem. -### Are circular component hierarchies supported? - -A circular component hierarchy is one where Component A includes Component B, which might conditionally include Component A again. Because the compiler inlines components, circular components are not supported and may result in infinite loops. - ### Why build all of this? Because I wanted to. From b5fb64a6bf6896e75cf2c77dcfc8ccfe7986851b Mon Sep 17 00:00:00 2001 From: John Koster Date: Sat, 8 Feb 2025 15:04:08 -0600 Subject: [PATCH 12/12] Improves compilation of custom functions declared within a component's template --- CHANGELOG.md | 4 ++ src/Compiler/ComponentCompiler.php | 2 + .../ComponentStages/RewriteFunctions.php | 27 +++++++ src/Parser/Visitors/FindFunctionsVisitor.php | 25 +++++++ src/Parser/Visitors/RenameFunctionVisitor.php | 70 +++++++++++++++++++ tests/Compiler/CustomFunctionsTest.php | 22 ++++++ .../components/functions/declared.blade.php | 9 +++ 7 files changed, 159 insertions(+) create mode 100644 src/Compiler/ComponentStages/RewriteFunctions.php create mode 100644 src/Parser/Visitors/FindFunctionsVisitor.php create mode 100644 src/Parser/Visitors/RenameFunctionVisitor.php create mode 100644 tests/Compiler/CustomFunctionsTest.php create mode 100644 tests/resources/components/functions/declared.blade.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 09b2e4a..6116e4d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## Unreleased + +- Improves compilation of custom functions declared within a component's template + ## [v1.0.6](https://github.com/Stillat/dagger/compare/v1.0.5...v1.0.6) - 2025-01-31 - Corrects an issue where Blade stack compilation results in array index errors diff --git a/src/Compiler/ComponentCompiler.php b/src/Compiler/ComponentCompiler.php index 5417ff8..e19e984 100644 --- a/src/Compiler/ComponentCompiler.php +++ b/src/Compiler/ComponentCompiler.php @@ -8,6 +8,7 @@ use Stillat\Dagger\Compiler\ComponentStages\ExtractsRenderCalls; use Stillat\Dagger\Compiler\ComponentStages\RemoveUseStatements; use Stillat\Dagger\Compiler\ComponentStages\ResolveNamespaces; +use Stillat\Dagger\Compiler\ComponentStages\RewriteFunctions; use Stillat\Dagger\Compiler\Concerns\CompilesPhp; use Stillat\Dagger\Parser\PhpParser; @@ -42,6 +43,7 @@ public function compile(string $component): string ResolveNamespaces::class, RemoveUseStatements::class, new ExtractsRenderCalls($this), + RewriteFunctions::class, ]) ->thenReturn(); diff --git a/src/Compiler/ComponentStages/RewriteFunctions.php b/src/Compiler/ComponentStages/RewriteFunctions.php new file mode 100644 index 0000000..a168e38 --- /dev/null +++ b/src/Compiler/ComponentStages/RewriteFunctions.php @@ -0,0 +1,27 @@ +addVisitor($finder); + $traverser->traverse($ast); + + $traverser->removeVisitor($finder); + + $modifyFunctionsVisitor = new RenameFunctionVisitor($finder->getFunctionNames()); + $traverser->addVisitor($modifyFunctionsVisitor); + + return $next($traverser->traverse($ast)); + } +} diff --git a/src/Parser/Visitors/FindFunctionsVisitor.php b/src/Parser/Visitors/FindFunctionsVisitor.php new file mode 100644 index 0000000..65ac01b --- /dev/null +++ b/src/Parser/Visitors/FindFunctionsVisitor.php @@ -0,0 +1,25 @@ +functionNames[] = $node->name->toString(); + } + + public function getFunctionNames(): array + { + return $this->functionNames; + } +} diff --git a/src/Parser/Visitors/RenameFunctionVisitor.php b/src/Parser/Visitors/RenameFunctionVisitor.php new file mode 100644 index 0000000..de494af --- /dev/null +++ b/src/Parser/Visitors/RenameFunctionVisitor.php @@ -0,0 +1,70 @@ +functionNames = $this->buildFunctionNameMap($functionNames); + } + + protected function buildFunctionNameMap(array $functionNames): array + { + return collect($functionNames) + ->mapWithKeys(function ($name) { + return [$name => $name.'_'.Utils::makeRandomString()]; + }) + ->all(); + } + + public function enterNode(Node $node) + { + if (! $node instanceof Node\Expr\FuncCall || ! $node->name instanceof Node\Name) { + return; + } + + $functionName = $node->name->toString(); + + if (! isset($this->functionNames[$functionName])) { + return; + } + + $node->name = new Node\Name($this->functionNames[$functionName]); + } + + public function leaveNode(Node $node) + { + if (! $node instanceof Node\Stmt\Function_) { + return null; + } + + $functionName = $node->name->toString(); + + if (! isset($this->functionNames[$functionName])) { + return null; + } + + $newFunctionName = $this->functionNames[$functionName]; + + $node->name = new Node\Identifier($newFunctionName); + + return new Node\Stmt\If_( + new Node\Expr\BooleanNot( + new Node\Expr\FuncCall( + new Node\Name('function_exists'), + [new Node\Arg(new Node\Scalar\String_($newFunctionName))] + ) + ), + [ + 'stmts' => [$node], + ] + ); + } +} diff --git a/tests/Compiler/CustomFunctionsTest.php b/tests/Compiler/CustomFunctionsTest.php new file mode 100644 index 0000000..6bbf743 --- /dev/null +++ b/tests/Compiler/CustomFunctionsTest.php @@ -0,0 +1,22 @@ + +@endfor +BLADE; + + $expected = <<<'EXPECTED' +THE TITLE: 0THE TITLE: 1THE TITLE: 2THE TITLE: 3THE TITLE: 4 +EXPECTED; + + $this->assertSame( + $expected, + $this->render($template) + ); +}); diff --git a/tests/resources/components/functions/declared.blade.php b/tests/resources/components/functions/declared.blade.php new file mode 100644 index 0000000..1935017 --- /dev/null +++ b/tests/resources/components/functions/declared.blade.php @@ -0,0 +1,9 @@ +props(['title'])->trimOutput(); + +function toUpper($value) { + return mb_strtoupper($value); +} +?> + +{{ toUpper($title) }}