From 4ecdd9b3c6280e31a8c2e0bafe75d27c0623847a Mon Sep 17 00:00:00 2001 From: John Koster Date: Thu, 31 Oct 2024 13:04:47 -0500 Subject: [PATCH] [5.x] Statamic Tag Blade Compiler (#10967) Co-authored-by: Jason Varga --- composer.json | 4 +- src/Auth/Protect/Tags.php | 4 +- src/Auth/UserTags.php | 14 +- src/Contracts/View/TagRenderer.php | 10 + src/Fields/ArrayableString.php | 32 +- src/Fields/Value.php | 59 ++- src/Forms/Tags.php | 14 +- src/Providers/ViewServiceProvider.php | 33 ++ src/Tags/Dictionary/DictionaryItem.php | 31 +- src/Tags/GetSite.php | 6 +- src/Tags/Glide.php | 14 +- src/Tags/ParentTags.php | 2 +- src/Tags/Partial.php | 15 +- src/Tags/Scope.php | 9 + src/Tags/Tags.php | 47 ++ src/Tags/UserGroups.php | 4 +- src/Tags/UserRoles.php | 4 +- .../Antlers/Language/Nodes/AntlersNode.php | 6 +- src/View/Blade/BladeTagHost.php | 260 +++++++++++ .../Blade/Concerns/CompilesComponents.php | 186 ++++++++ src/View/Blade/Concerns/CompilesNavs.php | 24 ++ src/View/Blade/Concerns/CompilesNocache.php | 19 + src/View/Blade/Concerns/CompilesPartials.php | 157 +++++++ src/View/Blade/StatamicTagCompiler.php | 175 ++++++++ src/View/Blade/TagRenderer.php | 19 + src/View/Blade/helpers.php | 40 ++ tests/FakesViews.php | 6 + tests/Fields/ArrayableStringTest.php | 11 + tests/Fields/ValueTest.php | 123 ++++++ tests/Tags/Dictionary/DictionaryItemTest.php | 35 ++ .../{ => Dictionary}/DictionaryTagTest.php | 2 +- tests/View/Antlers/ViewTest.php | 2 +- .../ComponentCompilerTest.php | 402 ++++++++++++++++++ .../AntlersComponents/NavCompilerTest.php | 235 ++++++++++ .../AntlersComponents/PartialCompilerTest.php | 383 +++++++++++++++++ .../AntlersComponents/ReturnValuesTest.php | 177 ++++++++ .../Blade/AntlersComponents/ScopeTagTest.php | 30 ++ .../AntlersComponents/SelfClosingTagsTest.php | 54 +++ .../AntlersComponents/TagContentsTest.php | 41 ++ 39 files changed, 2655 insertions(+), 34 deletions(-) create mode 100644 src/Contracts/View/TagRenderer.php create mode 100644 src/View/Blade/BladeTagHost.php create mode 100644 src/View/Blade/Concerns/CompilesComponents.php create mode 100644 src/View/Blade/Concerns/CompilesNavs.php create mode 100644 src/View/Blade/Concerns/CompilesNocache.php create mode 100644 src/View/Blade/Concerns/CompilesPartials.php create mode 100644 src/View/Blade/StatamicTagCompiler.php create mode 100644 src/View/Blade/TagRenderer.php create mode 100644 src/View/Blade/helpers.php create mode 100644 tests/Tags/Dictionary/DictionaryItemTest.php rename tests/Tags/{ => Dictionary}/DictionaryTagTest.php (98%) create mode 100644 tests/View/Blade/AntlersComponents/ComponentCompilerTest.php create mode 100644 tests/View/Blade/AntlersComponents/NavCompilerTest.php create mode 100644 tests/View/Blade/AntlersComponents/PartialCompilerTest.php create mode 100644 tests/View/Blade/AntlersComponents/ReturnValuesTest.php create mode 100644 tests/View/Blade/AntlersComponents/ScopeTagTest.php create mode 100644 tests/View/Blade/AntlersComponents/SelfClosingTagsTest.php create mode 100644 tests/View/Blade/AntlersComponents/TagContentsTest.php diff --git a/composer.json b/composer.json index 9255f69b7e..9a87a4d88f 100644 --- a/composer.json +++ b/composer.json @@ -28,6 +28,7 @@ "spatie/blink": "^1.3", "spatie/ignition": "^1.12", "statamic/stringy": "^3.1.2", + "stillat/blade-parser": "^1.10.1", "symfony/lock": "^6.4", "symfony/var-exporter": "^6.0", "symfony/yaml": "^6.0 || ^7.0", @@ -80,7 +81,8 @@ }, "files": [ "src/helpers.php", - "src/namespaced_helpers.php" + "src/namespaced_helpers.php", + "src/View/Blade/helpers.php" ] }, "autoload-dev": { diff --git a/src/Auth/Protect/Tags.php b/src/Auth/Protect/Tags.php index 6d0b139b4e..820aa8ccaa 100644 --- a/src/Auth/Protect/Tags.php +++ b/src/Auth/Protect/Tags.php @@ -30,6 +30,8 @@ public function passwordForm() $errors = session('errors', new ViewErrorBag)->passwordProtect; $data = [ + 'no_token' => false, + 'invalid_token' => false, 'errors' => $errors->toArray(), 'error' => $errors->first(), ]; @@ -37,7 +39,7 @@ public function passwordForm() $action = route('statamic.protect.password.store'); $method = 'POST'; - if (! $this->parser) { + if (! $this->canParseContents()) { return array_merge([ 'attrs' => $this->formAttrs($action, $method), 'params' => array_merge($this->formMetaPrefix($this->formParams($method)), [ diff --git a/src/Auth/UserTags.php b/src/Auth/UserTags.php index bfa6a3cd2c..f1adc46646 100644 --- a/src/Auth/UserTags.php +++ b/src/Auth/UserTags.php @@ -78,7 +78,7 @@ public function index() } } - return $user; + return $this->aliasedResult($user); } /** @@ -117,7 +117,7 @@ public function loginForm() $params['error_redirect'] = $this->parseRedirect($errorRedirect); } - if (! $this->parser) { + if (! $this->canParseContents()) { return array_merge([ 'attrs' => $this->formAttrs($action, $method, $knownParams), 'params' => $this->formMetaPrefix($this->formParams($method, $params)), @@ -163,7 +163,7 @@ public function registerForm() $params['error_redirect'] = $this->parseRedirect($errorRedirect); } - if (! $this->parser) { + if (! $this->canParseContents()) { return array_merge([ 'attrs' => $this->formAttrs($action, $method, $knownParams), 'params' => $this->formMetaPrefix($this->formParams($method, $params)), @@ -213,7 +213,7 @@ public function profileForm() $action = route('statamic.profile'); $method = 'POST'; - if (! $this->parser) { + if (! $this->canParseContents()) { return array_merge([ 'attrs' => $this->formAttrs($action, $method, $knownParams), 'params' => $this->formMetaPrefix($this->formParams($method, $params)), @@ -263,7 +263,7 @@ public function passwordForm() $params['error_redirect'] = $this->parseRedirect($errorRedirect); } - if (! $this->parser) { + if (! $this->canParseContents()) { return array_merge([ 'attrs' => $this->formAttrs($action, $method, $knownParams), 'params' => $this->formMetaPrefix($this->formParams($method, $params)), @@ -354,7 +354,7 @@ public function forgotPasswordForm() $params['reset_url'] = $resetUrl; } - if (! $this->parser) { + if (! $this->canParseContents()) { return array_merge([ 'attrs' => $this->formAttrs($action, $method, $knownParams), 'params' => $this->formMetaPrefix($this->formParams($method, $params)), @@ -412,7 +412,7 @@ public function resetPasswordForm() $params['error_redirect'] = $errorRedirect; } - if (! $this->parser) { + if (! $this->canParseContents()) { return array_merge([ 'attrs' => $this->formAttrs($action, $method, $knownParams), 'params' => array_merge($this->formMetaPrefix($this->formParams($method, $params)), [ diff --git a/src/Contracts/View/TagRenderer.php b/src/Contracts/View/TagRenderer.php new file mode 100644 index 0000000000..dc6ac0a251 --- /dev/null +++ b/src/Contracts/View/TagRenderer.php @@ -0,0 +1,10 @@ +toArray(); } + + #[\ReturnTypeWillChange] + public function offsetExists(mixed $offset) + { + $value = $this->toArray(); + + if (! is_array($value)) { + return false; + } + + return array_key_exists($offset, $value); + } + + #[\ReturnTypeWillChange] + public function offsetGet(mixed $offset) + { + return $this->toArray()[$offset]; + } + + #[\ReturnTypeWillChange] + public function offsetSet(mixed $offset, mixed $value) + { + + } + + #[\ReturnTypeWillChange] + public function offsetUnset(mixed $offset) + { + } } diff --git a/src/Fields/Value.php b/src/Fields/Value.php index dd71177f58..d4a95503d4 100644 --- a/src/Fields/Value.php +++ b/src/Fields/Value.php @@ -2,6 +2,7 @@ namespace Statamic\Fields; +use ArrayAccess; use ArrayIterator; use Illuminate\Support\Collection; use IteratorAggregate; @@ -12,7 +13,7 @@ use Statamic\Support\Str; use Statamic\View\Antlers\Language\Parser\DocumentTransformer; -class Value implements IteratorAggregate, JsonSerializable +class Value implements ArrayAccess, IteratorAggregate, JsonSerializable { private $resolver; protected $raw; @@ -87,6 +88,21 @@ public function value() return $value; } + private function iteratorValue() + { + $value = $this->value(); + + if (Compare::isQueryBuilder($value)) { + $value = $value->get(); + } + + if ($value instanceof Collection) { + $value = $value->all(); + } + + return $value; + } + public function __toString() { return (string) $this->value(); @@ -111,7 +127,7 @@ public function jsonSerialize($options = 0) #[\ReturnTypeWillChange] public function getIterator() { - return new ArrayIterator($this->value()); + return new ArrayIterator($this->iteratorValue()); } public function shouldParseAntlers() @@ -203,4 +219,43 @@ public function __serialize(): array return get_object_vars($this); } + + public function __call(string $name, array $arguments) + { + return $this->value()->{$name}(...$arguments); + } + + public function __get($key) + { + return $this->value()?->{$key} ?? null; + } + + #[\ReturnTypeWillChange] + public function offsetExists(mixed $offset) + { + $value = $this->value(); + + if (! is_array($value)) { + return false; + } + + return array_key_exists($offset, $value); + } + + #[\ReturnTypeWillChange] + public function offsetGet(mixed $offset) + { + return $this->value()[$offset]; + } + + #[\ReturnTypeWillChange] + public function offsetSet(mixed $offset, mixed $value) + { + + } + + #[\ReturnTypeWillChange] + public function offsetUnset(mixed $offset) + { + } } diff --git a/src/Forms/Tags.php b/src/Forms/Tags.php index ac27445236..f44018e7c6 100644 --- a/src/Forms/Tags.php +++ b/src/Forms/Tags.php @@ -51,7 +51,7 @@ public function set() { $this->context['form'] = $this->params->get(static::HANDLE_PARAM); - return []; + return $this->parse(); } /** @@ -114,7 +114,7 @@ public function create() $params['error_redirect'] = $this->parseRedirect($errorRedirect); } - if (! $this->parser) { + if (! $this->canParseContents()) { return array_merge([ 'attrs' => $this->formAttrs($action, $method, $knownParams, $attrs), 'params' => $this->formMetaPrefix($this->formParams($method, $params)), @@ -167,9 +167,13 @@ public function errors() public function success() { $sessionHandle = $this->sessionHandle(); + $successMessage = $this->getFromFormSession($sessionHandle, 'success'); - // TODO: Should probably output success string instead of `true` boolean for consistency. - return $this->getFromFormSession($sessionHandle, 'success'); + if ($this->isAntlersBladeComponent() && $this->isPair) { + return str($successMessage)->length() > 0; + } + + return $successMessage; } /** @@ -180,7 +184,7 @@ public function success() public function submission() { if ($this->success()) { - return session('submission')->toArray(); + return $this->aliasedResult(session('submission')->toArray()); } } diff --git a/src/Providers/ViewServiceProvider.php b/src/Providers/ViewServiceProvider.php index 1818212d84..f944749278 100644 --- a/src/Providers/ViewServiceProvider.php +++ b/src/Providers/ViewServiceProvider.php @@ -5,6 +5,7 @@ use Illuminate\Support\Facades\Blade; use Illuminate\Support\Facades\View as ViewFactory; use Illuminate\Support\ServiceProvider; +use Illuminate\Support\Str; use Illuminate\View\View; use Statamic\Contracts\View\Antlers\Parser as ParserContract; use Statamic\Facades\Site; @@ -20,6 +21,7 @@ use Statamic\View\Antlers\Language\Runtime\Tracing\TraceManager; use Statamic\View\Antlers\Language\Utilities\StringUtilities; use Statamic\View\Blade\AntlersBladePrecompiler; +use Statamic\View\Blade\StatamicTagCompiler; use Statamic\View\Cascade; use Statamic\View\Debugbar\AntlersProfiler\PerformanceCollector; use Statamic\View\Debugbar\AntlersProfiler\PerformanceTracer; @@ -163,6 +165,33 @@ public function registerBladeDirectives() Blade::directive('cascade', function ($expression) { return ""; }); + Blade::directive('frontmatter', function ($exp) { + return ""; + }); + Blade::directive('recursive_children', function ($exp) { + $nested = $exp ?? '$children'; + + if (! $nested) { + $nested = '$children'; + } + + $recursiveChildren = <<<'PHP' +@include('compiled__views::'.$__currentStatamicNavView, array_merge(get_defined_vars(), [ + 'depth' => ($depth ?? 0) + 1, + '__statamicOverrideTagResultValue' => #varName#, +])) +PHP; + + $recursiveChildren = Str::swap([ + '#varName#' => $nested, + ], $recursiveChildren); + + return Blade::compileString($recursiveChildren); + }); + } public function boot() @@ -171,6 +200,10 @@ public function boot() $this->registerBladeDirectives(); + Blade::precompiler(function ($content) { + return (new StatamicTagCompiler())->compile($content); + }); + Blade::precompiler(function ($content) { return AntlersBladePrecompiler::compile($content); }); diff --git a/src/Tags/Dictionary/DictionaryItem.php b/src/Tags/Dictionary/DictionaryItem.php index f155578e23..77e35e41bd 100644 --- a/src/Tags/Dictionary/DictionaryItem.php +++ b/src/Tags/Dictionary/DictionaryItem.php @@ -2,11 +2,12 @@ namespace Statamic\Tags\Dictionary; +use ArrayAccess; use Illuminate\Contracts\Support\Arrayable; use Illuminate\Support\Arr; use Statamic\Data\ContainsSupplementalData; -class DictionaryItem implements Arrayable +class DictionaryItem implements Arrayable, ArrayAccess { use ContainsSupplementalData; @@ -23,4 +24,32 @@ public function toArray() { return array_merge($this->data, $this->supplements ?? []); } + + public function __get($key) + { + return $this->toArray()[$key] ?? null; + } + + #[\ReturnTypeWillChange] + public function offsetExists(mixed $offset) + { + return array_key_exists($offset, $this->toArray()); + } + + #[\ReturnTypeWillChange] + public function offsetGet(mixed $offset) + { + return $this->toArray()[$offset]; + } + + #[\ReturnTypeWillChange] + public function offsetSet(mixed $offset, mixed $value) + { + + } + + #[\ReturnTypeWillChange] + public function offsetUnset(mixed $offset) + { + } } diff --git a/src/Tags/GetSite.php b/src/Tags/GetSite.php index 82ac0bb72c..22a1647140 100644 --- a/src/Tags/GetSite.php +++ b/src/Tags/GetSite.php @@ -18,9 +18,11 @@ public function wildcard($tag) throw new \Exception("Site [$handle] does not exist."); } - return (Str::contains($tag, ':') && ($var = Str::after($tag, ':'))) + $result = (Str::contains($tag, ':') && ($var = Str::after($tag, ':'))) ? $site->$var : $site; + + return $this->aliasedResult($result); } /** @@ -36,6 +38,6 @@ public function index() throw new \Exception("Site [$handle] does not exist."); } - return $site; + return $this->aliasedResult($site); } } diff --git a/src/Tags/Glide.php b/src/Tags/Glide.php index 1f89c2ea49..f34deb64d5 100644 --- a/src/Tags/Glide.php +++ b/src/Tags/Glide.php @@ -119,7 +119,7 @@ public function dataUri() * * Generates the image and makes variables available within the pair. * - * @return string + * @return string|array */ public function generate($items = null) { @@ -131,7 +131,7 @@ public function generate($items = null) $items = is_iterable($items) ? collect($items) : collect([$items]); - return $items->map(function ($item) { + $items = $items->map(function ($item) { try { $data = ['url' => $this->generateGlideUrl($item)]; @@ -150,6 +150,14 @@ public function generate($items = null) \Log::error($e->getMessage()); } })->filter()->all(); + + if ($alias = $this->params->get('as')) { + return [ + $alias => $items, + ]; + } + + return $items; } /** @@ -304,7 +312,7 @@ private function getManipulationParams() { $params = collect(); - foreach ($this->params as $param => $value) { + foreach (collect($this->params)->except('as') as $param => $value) { if (! in_array($param, ['src', 'id', 'path', 'tag', 'alt', 'absolute'])) { $params->put($param, $value); } diff --git a/src/Tags/ParentTags.php b/src/Tags/ParentTags.php index b5ff77af09..6e0586f751 100644 --- a/src/Tags/ParentTags.php +++ b/src/Tags/ParentTags.php @@ -85,7 +85,7 @@ private function getParent() // Find the parent by stripping away URL segments foreach ($segment_urls as $segment_url) { if ($content = Entry::findByUri($segment_url, Site::current())) { - return $content->toAugmentedArray(); + return $this->aliasedResult($content->toAugmentedArray()); } } diff --git a/src/Tags/Partial.php b/src/Tags/Partial.php index 2732b92839..f487f31faa 100644 --- a/src/Tags/Partial.php +++ b/src/Tags/Partial.php @@ -2,6 +2,8 @@ namespace Statamic\Tags; +use Illuminate\Support\HtmlString; + class Partial extends Tags { public function wildcard($tag) @@ -21,7 +23,7 @@ protected function render($partial) $variables = array_merge($this->context->all(), $this->params->all(), [ '__frontmatter' => $this->params->all(), - 'slot' => $this->isPair ? trim($this->parse()) : null, + 'slot' => $this->isPair ? $this->getSlotContent() : null, ]); return view($this->viewName($partial), $variables) @@ -29,6 +31,17 @@ protected function render($partial) ->render(); } + private function getSlotContent() + { + $content = trim($this->parse()); + + if ($this->isAntlersBladeComponent()) { + return new HtmlString($content); + } + + return $content; + } + protected function shouldRender(): bool { if ($this->params->has('when')) { diff --git a/src/Tags/Scope.php b/src/Tags/Scope.php index 54edc29879..4b535029aa 100644 --- a/src/Tags/Scope.php +++ b/src/Tags/Scope.php @@ -10,6 +10,15 @@ public function wildcard($method) { throw_unless($this->isPair, new \Exception('Scope tag must be a pair')); + if ($this->tagRenderer) { + // This could *probably* be the return value without this condition. It doesn't + // seem like the cascade needs to be set below. Adding this as a condition so + // for now though so it doesn't unintentionally break anything. + return [ + $this->method => $this->context->all(), + ]; + } + app(Cascade::class)->set($this->method, $this->context->all()); return $this->context->all(); diff --git a/src/Tags/Tags.php b/src/Tags/Tags.php index 5063e0c561..b001cf348e 100644 --- a/src/Tags/Tags.php +++ b/src/Tags/Tags.php @@ -3,6 +3,7 @@ namespace Statamic\Tags; use Illuminate\Support\Traits\Macroable; +use Statamic\Contracts\View\TagRenderer; use Statamic\Extend\HasAliases; use Statamic\Extend\HasHandle; use Statamic\Extend\RegistersItself; @@ -85,6 +86,13 @@ abstract class Tags */ protected $wildcardHandled; + /** + * A custom tag renderer that may be used when no Antlers parser is available. + * + * @var TagRenderer|null + */ + protected $tagRenderer; + public function setProperties($properties) { $this->setParser($properties['parser']); @@ -126,6 +134,32 @@ public function setParameters($parameters) return $this; } + public function setTagRenderer($tagRenderer) + { + $this->tagRenderer = $tagRenderer; + + return $this; + } + + protected function templatingLanguage() + { + if ($this->tagRenderer) { + return $this->tagRenderer->getLanguage(); + } + + return 'antlers'; + } + + protected function isAntlersBladeComponent() + { + return $this->templatingLanguage() === 'blade'; + } + + protected function canParseContents() + { + return $this->parser != null || $this->tagRenderer != null; + } + /** * Handle missing methods. * @@ -165,6 +199,10 @@ public function parse($data = []) } if (! $this->parser) { + if ($this->tagRenderer) { + return $this->tagRenderer->render($this->content, array_merge($this->context->all(), $data)); + } + return $data; } @@ -175,6 +213,15 @@ public function parse($data = []) }); } + protected function aliasedResult($data) + { + if ($as = $this->params->get('as')) { + return [$as => $data]; + } + + return $data; + } + /** * Iterate over the data and parse the tag pair contents for each. * diff --git a/src/Tags/UserGroups.php b/src/Tags/UserGroups.php index 7b947ab11a..2121dab160 100644 --- a/src/Tags/UserGroups.php +++ b/src/Tags/UserGroups.php @@ -18,9 +18,9 @@ public function index() } if (empty($handles)) { - return $groups->values(); + return $this->aliasedResult($groups->values()); } - return $groups->filter(fn ($group) => in_array($group->handle(), $handles))->values(); + return $this->aliasedResult($groups->filter(fn ($group) => in_array($group->handle(), $handles))->values()); } } diff --git a/src/Tags/UserRoles.php b/src/Tags/UserRoles.php index 93868f3533..14c5894973 100644 --- a/src/Tags/UserRoles.php +++ b/src/Tags/UserRoles.php @@ -18,9 +18,9 @@ public function index() } if (empty($handles)) { - return $roles->values(); + return $this->aliasedResult($roles->values()); } - return $roles->filter(fn ($role) => in_array($role->handle(), $handles))->values(); + return $this->aliasedResult($roles->filter(fn ($role) => in_array($role->handle(), $handles))->values()); } } diff --git a/src/View/Antlers/Language/Nodes/AntlersNode.php b/src/View/Antlers/Language/Nodes/AntlersNode.php index 86241e6901..66f8ce3bfb 100644 --- a/src/View/Antlers/Language/Nodes/AntlersNode.php +++ b/src/View/Antlers/Language/Nodes/AntlersNode.php @@ -362,7 +362,7 @@ public function getParameterValues(NodeProcessor $processor, $data = []) foreach ($this->parameters as $param) { $value = $this->getSingleParameterValue($param, $processor, $data); - if ($this->isVoidValue($value)) { + if (self::isVoidValue($value)) { continue; } @@ -376,7 +376,7 @@ public function getParameterValues(NodeProcessor $processor, $data = []) return $values; } - protected function isVoidValue($value) + public static function isVoidValue($value) { return is_string($value) && $value == 'void::'.GlobalRuntimeState::$environmentId; } @@ -398,7 +398,7 @@ public function getSingleParameterValueByName($parameterName, NodeProcessor $pro if ($param->name == $parameterName) { $value = $this->getSingleParameterValue($param, $processor, $data); - if ($this->isVoidValue($value)) { + if (self::isVoidValue($value)) { break; } diff --git a/src/View/Blade/BladeTagHost.php b/src/View/Blade/BladeTagHost.php new file mode 100644 index 0000000000..a1d4091558 --- /dev/null +++ b/src/View/Blade/BladeTagHost.php @@ -0,0 +1,260 @@ +context = $context; + } + + public function setParams(array $params): static + { + $this->params = $params; + + return $this; + } + + public function getContext(): array + { + return $this->context; + } + + public function setTag(Tags $tag, string $method): static + { + $this->tag = $tag; + $this->method = $method; + + return $this; + } + + public function hasProtectedVar(string $name): bool + { + return isset($this->context[$name]); + } + + public function getProtectedVar(string $name): mixed + { + return $this->context[$name]; + } + + public function shouldRenderCompiledContent(): bool + { + return $this->isAssociativeArray() || $this->tagValue === true; + } + + public function getDefaultProtectedVariables(): array + { + return $this->protectedVariables; + } + + public function getProtectedVariables(): array + { + if ($this->isAssociativeArray()) { + return array_merge($this->protectedVariables, array_keys($this->tagValue)); + } + + return $this->protectedVariables; + } + + public function setIsPair(bool $isPair): static + { + $this->isPair = $isPair; + + return $this; + } + + public function setContent(string $content): static + { + $this->content = $content; + + return $this; + } + + public function render(): mixed + { + $method = $this->method; + $this->tag->isPair = $this->isPair; + $this->tag->setContext($this->context); + + $this->tag->setTagRenderer(app(TagRenderer::class)); + + if ($this->isPair) { + $this->tag->setContent($this->content); + $this->tag->isPair = true; + } + + $this->originalValue = $this->tag->{$method}(); + + return $this->tagValue = self::adjustBladeValue($this->originalValue); + } + + public function setValue(mixed $value): static + { + $this->originalValue = $value; + $this->tagValue = self::adjustBladeValue($value); + + return $this; + } + + public function hasScope(): bool + { + return array_key_exists('scope', $this->params); + } + + public function getScopeName(): string + { + return $this->params['scope']; + } + + public function getOriginalValue(): mixed + { + return $this->originalValue; + } + + public function getValue(): mixed + { + if (! $this->isPair && ! $this->tagValue) { + return ''; + } + + if ($this->hasAlias()) { + return [ + $this->getAlias() => $this->tagValue, + ]; + } + + if ($this->shouldAddValue()) { + return $this->addValueKey($this->tagValue); + } + + return $this->tagValue; + } + + protected function shouldAddValue(): bool + { + $isCandidate = $this->isPair && + is_array($this->tagValue) && + ! Arr::isAssoc($this->tagValue); + + if (! $isCandidate) { + return false; + } + + foreach ($this->tagValue as $value) { + if (is_array($value) && Arr::isAssoc($value)) { + return false; + } + + if ($value instanceof Augmentable || $value instanceof Arrayable) { + return false; + } + } + + return true; + } + + protected function addValueKey(array $value): array + { + return collect($value) + ->map(fn ($value) => ['value' => $value]) + ->all(); + } + + public function hasTag(): bool + { + return $this->tag != null; + } + + public static function filterParams(array $params): array + { + $values = []; + + foreach ($params as $key => $value) { + if (AntlersNode::isVoidValue($value)) { + continue; + } + + $values[$key] = $value; + } + + return $values; + } + + public static function adjustBladeValue(mixed $value): mixed + { + if ($value instanceof Value) { + $value = $value->value(); + } + + if ($value instanceof Collection) { + $value = $value->all(); + } + + if ($value instanceof Augmentable) { + $value = $value->toDeferredAugmentedArray(); + } + + if ($value instanceof Arrayable) { + $value = $value->toArray(); + } + + return $value; + } + + public function getAlias(): string + { + return (string) $this->params['as']; + } + + public function hasAlias(): bool + { + return array_key_exists('as', $this->params) && $this->tag instanceof Structure; + } + + public function isAssociativeArray(): bool + { + return is_array($this->getValue()) && Arr::isAssoc($this->getValue()); + } + + public function isArray(): bool + { + return is_array($this->tagValue); + } + + public function isEmpty(): bool + { + return count($this->tagValue) === 0; + } + + public function canRenderAsString(): bool + { + return is_string($this->tagValue) || is_numeric($this->tagValue); + } + + public function renderString(): string + { + return (string) $this->tagValue; + } +} diff --git a/src/View/Blade/Concerns/CompilesComponents.php b/src/View/Blade/Concerns/CompilesComponents.php new file mode 100644 index 0000000000..7471780c34 --- /dev/null +++ b/src/View/Blade/Concerns/CompilesComponents.php @@ -0,0 +1,186 @@ +tagName == 'no_results'; + } + + protected function extractNoResults(ComponentNode $componentNode): array + { + $newContent = ''; + $noResult = null; + + foreach ($componentNode->getRootNodes() as $node) { + if ( + $node instanceof ComponentNode && + $node->parent === $componentNode && + $this->isNoResultTag($node) + + ) { + $noResult = $node; + + continue; + } + + if ($node instanceof ComponentNode) { + $newContent .= $this->getComponentContent($node); + } elseif ($node instanceof LiteralNode) { + $newContent .= $node->unescapedContent; + } + } + + return [$newContent, $noResult]; + } + + protected function compileComponent(ComponentNode $component): string + { + [$compilerContent, $noResult] = $this->extractNoResults($component); + + $compiledNoResult = ''; + + if ($noResult) { + $compiledNoResult = $this->compile($noResult->innerDocumentContent); + } + + $componentTemplate = <<<'PHP' +setContent(base64_decode($__statamicResultVarSuffixTagContent)); +$__statamicBladeHostVarSuffix->setTag( + app(\Statamic\Tags\Loader::class)->load('$tagName', [ + 'parser' => null, + 'params' => $params, + 'content' => '', + 'context' => [], + 'tag' => '$fullTagName', + 'tag_method' => $originalMethod, + ]), + $tagMethod +)->setIsPair($isPair)->setParams($params); + +/** Allows for navs to override values for recursive children. */ +if (isset($__statamicOverrideTagResultValue)) { + $__statamicBladeHostVarSuffix->setValue($__statamicOverrideTagResultValue); + unset($__statamicOverrideTagResultValue); +} else { + $__statamicBladeHostVarSuffix->render(); +} +unset($__statamicResultVarSuffixTagContent); +#prepend# +if ($__statamicBladeHostVarSuffix->isAssociativeArray()) { + /** Create variables from the array values. */ + foreach ($__statamicBladeHostVarSuffix->getValue() as $__key => $__value) { + $$__key = $__value; + } + unset($__value); +} elseif ($__statamicBladeHostVarSuffix->isArray()) { + $__currentLoopData = $__statamicBladeHostVarSuffix->getValue(); + + if ($__statamicBladeHostVarSuffix->isEmpty()) { + ?>#compiledEmpty#addLoop($__currentLoopData); + + /** Iterate the tag's results */ + foreach ($__currentLoopData as $__statamicLoopValueVarSuffix) { + $__env->incrementLoopIndices(); + /** Make $loop variable available to the user. */ + $loop = $__env->getLastLoop(); + /** Make a copy of the variables we want to restore. */ + $__statamicStachedVarsVarSuffix = get_defined_vars(); + $__restoreLoopVariablesVarSuffix = $__statamicBladeHostVarSuffix->getDefaultProtectedVariables(); + + if ($__statamicBladeHostVarSuffix->hasScope()) { + ${$__statamicBladeHostVarSuffix->getScopeName()} = $__statamicLoopValueVarSuffix; + } else { + $__statamicLoopValueVarSuffix = \Statamic\View\Blade\BladeTagHost::adjustBladeValue($__statamicLoopValueVarSuffix); + + if (is_array($__statamicLoopValueVarSuffix) && \Illuminate\Support\Arr::isAssoc($__statamicLoopValueVarSuffix)) { + $__restoreLoopVariablesVarSuffix = array_merge($__restoreLoopVariablesVarSuffix, array_keys($__statamicLoopValueVarSuffix)); + + foreach ($__statamicLoopValueVarSuffix as $__key => $__value) { + $$__key = $__value; + } + unset($__value); + } + } + + /** The inner compiled content. */ + ?>#compiled#hasScope()) { + unset(${$__statamicBladeHostVarSuffix->getScopeName()}); + } + + /** Restore variables that may have been overwritten. */ + foreach ($__restoreLoopVariablesVarSuffix as $__key) { + if (isset($__statamicStachedVarsVarSuffix[$__key])) { + $$__key = $__statamicStachedVarsVarSuffix[$__key]; + } else { + unset($__key); + } + } + + /** Cleanup loop values. */ + unset( + $__value, + $__key, + $__statamicStachedVarsVarSuffix, + $__restoreLoopVariablesVarSuffix, + $__statamicLoopValueVarSuffix + ); + } + + $__env->popLoop(); + $loop = $__env->getLastLoop(); + } +} elseif ($__statamicBladeHostVarSuffix->canRenderAsString()) { + echo $__statamicBladeHostVarSuffix->renderString(); +} + +if ($__statamicBladeHostVarSuffix->shouldRenderCompiledContent()): +?>#compiled#getProtectedVariables() as $__key) { + if ($__statamicBladeHostVarSuffix->hasProtectedVar($__key)) { + $$__key = $__statamicBladeHostVarSuffix->getProtectedVar($__key); + } else { + unset($__key); + } +} +#append# +unset( + $__key, + $__statamicBladeHostVarSuffix +); +?> +PHP; + + $compiledNested = ''; + + if ($this->isPairedComponent($component)) { + $compiledNested = $this->compile($compilerContent); + } + + return $this->compileTemplate( + $component, + $componentTemplate, + $compiledNested, + additional: [ + '#compiledEmpty#' => $compiledNoResult, + ] + ); + } +} diff --git a/src/View/Blade/Concerns/CompilesNavs.php b/src/View/Blade/Concerns/CompilesNavs.php new file mode 100644 index 0000000000..3d92ba3dfc --- /dev/null +++ b/src/View/Blade/Concerns/CompilesNavs.php @@ -0,0 +1,24 @@ +outerDocumentContent); + + $compiled = (new StatamicTagCompiler()) + ->prependCompiledContent('$__currentStatamicNavView = \''.$viewName.'\';') + ->appendCompiledContent('unset($__currentStatamicNavView);') + ->setInterceptNav(false) + ->compile($component->outerDocumentContent); + + file_put_contents(storage_path('framework/views/'.$viewName.'.blade.php'), $compiled); + + return '@include(\'compiled__views::'.$viewName.'\', get_defined_vars())'; + } +} diff --git a/src/View/Blade/Concerns/CompilesNocache.php b/src/View/Blade/Concerns/CompilesNocache.php new file mode 100644 index 0000000000..50b07ab83d --- /dev/null +++ b/src/View/Blade/Concerns/CompilesNocache.php @@ -0,0 +1,19 @@ +compile($component->innerDocumentContent); + $viewName = '_nocache'.sha1($compiled); + $path = storage_path('framework/views/'.$viewName.'.blade.php'); + file_put_contents($path, $compiled); + + return '@nocache(\'compiled__views::'.$viewName.'\')'; + } +} diff --git a/src/View/Blade/Concerns/CompilesPartials.php b/src/View/Blade/Concerns/CompilesPartials.php new file mode 100644 index 0000000000..c265501b50 --- /dev/null +++ b/src/View/Blade/Concerns/CompilesPartials.php @@ -0,0 +1,157 @@ +startsWith(['slot.', 'slot:']); + } + + protected function isComponentSlot(ComponentNode $parent, ComponentNode $child): bool + { + return $child->parent === $parent && $this->isSlotTag($child->tagName); + } + + protected function extractSlots(ComponentNode $componentNode): array + { + $slots = []; + $newContent = ''; + + foreach ($componentNode->getRootNodes() as $node) { + if ($node instanceof ComponentNode && $this->isComponentSlot($componentNode, $node)) { + $slots[] = $node; + + continue; + } + + if ($node instanceof ComponentNode) { + $newContent .= $this->getComponentContent($node); + } elseif ($node instanceof LiteralNode) { + $newContent .= $node->unescapedContent; + } + } + + return [$slots, $newContent]; + } + + protected function compileSlot(ComponentNode $node): array + { + $name = (string) str($node->name)->substr(5); + $compiled = $this->compile($node->innerDocumentContent); + + return [$name, $compiled]; + } + + protected function compilePartial(ComponentNode $component): string + { + [$slots, $newContent] = $this->extractSlots($component); + $params = $component->getParameters()->keyBy(fn (ParameterNode $param) => $param->materializedName); + $forwardMethods = ['exists', 'if_exists']; + + if (str($component->tagName)->startsWith('partial:')) { + $partialName = (string) str($component->tagName)->substr(8); + + if (! in_array($partialName, $forwardMethods)) { + $srcParam = new ParameterNode(); + $srcParam->type = ParameterType::Parameter; + $srcParam->setName('src'); + $srcParam->setValue($partialName); + $params['src'] = $srcParam; + } + } + + $hoistedSet = ''; + $hoistedUnset = ''; + + $set = <<<'SET' +$$varName = <<<'COMPILED' +#compiled# +COMPILED; +SET; + $unset = <<<'UNSET' +unset($$varName); +UNSET; + + foreach ($slots as $slot) { + $hoistedVarName = '__partialSlot'.Str::random(32); + [$name, $compiled] = $this->compileSlot($slot); + $injectedParam = new ParameterNode(); + $injectedParam->setName($name); + $injectedParam->type = ParameterType::DynamicVariable; + + $injectedParam->value = 'new \Illuminate\Support\HtmlString(\Illuminate\Support\Facades\Blade::render($'.$hoistedVarName.', get_defined_vars()))'; + + $hoistedSet .= Str::swap([ + '$varName' => $hoistedVarName, + '#compiled#' => $compiled, + ], $set); + + $hoistedUnset .= Str::swap([ + '$varName' => $hoistedVarName, + ], $unset); + + $params[$name] = $injectedParam; + } + + $compiledNode = <<<'PHP' +setTag( + app(\Statamic\Tags\Loader::class) + ->load('$tagName', [ + 'parser' => null, + 'params' => $params, + 'content' => '', + 'context' => [], + 'tag' => '$fullTagName', + 'tag_method' => $originalMethod, + ]), $tagMethod)->setIsPair($isPair)->setContent(base64_decode($__statamicResultVarSuffixTagContent))->render(); + +if (is_string($__statamicResultVarSuffix)) { + echo (string) $__statamicResultVarSuffix; +} + +if (is_bool($__statamicResultVarSuffix) && $__statamicResultVarSuffix === true):?>#compiled# +PHP; + + [$name, $method, $originalMethod] = $this->extractMethodNames($component); + + if (! in_array(Str::snake($method), $forwardMethods)) { + $method = $originalMethod = 'index'; + } + + return $this->compileTemplate( + $component, + $compiledNode, + $newContent, + $params->toArray(), + [ + '#set#' => $hoistedSet, + '#unset#' => $hoistedUnset, + '$tagMethod' => "'".$method."'", + '$tagName' => 'partial', + '$originalMethod' => "'".$originalMethod."'", + ] + ); + } +} diff --git a/src/View/Blade/StatamicTagCompiler.php b/src/View/Blade/StatamicTagCompiler.php new file mode 100644 index 0000000000..45d7dfc0ad --- /dev/null +++ b/src/View/Blade/StatamicTagCompiler.php @@ -0,0 +1,175 @@ +attributeCompiler = (new AttributeCompiler()) + ->prefixEscapedParametersWith('attr:') + ->wrapResultIn(['as', 'scope'], function ($value) { + return "\\Statamic\\View\\Blade\\StatamicTagCompiler::adjustDynamicVariableName($value)"; + }); + } + + protected function getComponentContent(ComponentNode $node): string + { + if ($node->isClosedBy === null || $node->isSelfClosing) { + return $node->content; + } + + return $node->outerDocumentContent; + } + + public static function adjustDynamicVariableName(string $variableName): string + { + return ltrim($variableName, '$'); + } + + protected function compileParameters(array $params): string + { + return '\Statamic\View\Blade\BladeTagHost::filterParams('.$this->attributeCompiler->compile($params).')'; + } + + public function prependCompiledContent(string $content): static + { + $this->prependCompiledContent = $content; + + return $this; + } + + public function appendCompiledContent(string $content): static + { + $this->appendCompiledContent = $content; + + return $this; + } + + public function setInterceptNav(bool $interceptNav): static + { + $this->interceptNav = $interceptNav; + + return $this; + } + + public function compile(string $template): string + { + if (! Str::contains($template, ['registerCustomComponentTags($this->statamicTags) + ->onlyParseComponents() + ->parseTemplate($template) + ->toDocument() + ->getRootNodes() + ->map(function ($node) { + if (! $node instanceof ComponentNode) { + return $node->unescapedContent; + } + + if (! in_array(mb_strtolower($node->componentPrefix), $this->statamicTags)) { + return $node->outerDocumentContent; + } + + if ($node->isClosingTag && ! $node->isSelfClosing) { + return ''; + } + + if ($node->tagName === 'nocache') { + return $this->compileNocache($node); + } elseif ($this->isPartial($node)) { + return $this->compilePartial($node); + } elseif ($this->interceptNav && $this->isStructure($node->tagName)) { + return $this->compileNav($node); + } + + return $this->compileComponent($node); + })->join(''); + } + + protected function isStructure(string $tagName): bool + { + $tagName = (string) str($tagName)->before(':')->lower(); + + return in_array($tagName, ['nav', 'structure', 'children']); + } + + protected function isPartial(ComponentNode $component): bool + { + return $component->tagName == 'partial' || str($component->tagName)->lower()->startsWith('partial:'); + } + + protected function extractMethodNames(ComponentNode $component): array + { + $name = $component->tagName; + + if ($pos = strpos($name, ':')) { + $originalMethod = substr($name, $pos + 1); + $method = Str::camel($originalMethod); + $name = substr($name, 0, $pos); + } else { + $method = $originalMethod = 'index'; + } + + return [$name, $method, $originalMethod]; + } + + protected function isPairedComponent(ComponentNode $component): bool + { + return $component->isClosedBy != null && ! $component->isSelfClosing; + } + + protected function compileTemplate(ComponentNode $component, string $template, string $nestedContent, ?array $params = null, array $additional = []): string + { + if ($params === null) { + $params = $component->parameters; + } + + [$name, $method, $originalMethod] = $this->extractMethodNames($component); + + $isPair = 'false'; + + if ($this->isPairedComponent($component)) { + $isPair = 'true'; + } + + return (string) str($template) + ->swap(array_merge([ + '$tagName' => $name, + '$fullTagName' => $component->tagName, + '$tagMethod' => "'".$method."'", + '$originalMethod' => "'".$originalMethod."'", + '$params' => $this->compileParameters($params), + '$isPair' => $isPair, + '#compiled#' => $nestedContent, + '#compiledEncoded#' => base64_encode($nestedContent), + 'VarSuffix' => Str::random(32), + '#prepend#' => $this->prependCompiledContent, + '#append#' => $this->appendCompiledContent, + ], $additional)); + } +} diff --git a/src/View/Blade/TagRenderer.php b/src/View/Blade/TagRenderer.php new file mode 100644 index 0000000000..46aaa5821e --- /dev/null +++ b/src/View/Blade/TagRenderer.php @@ -0,0 +1,19 @@ +value(); + } elseif ($value instanceof Values) { + $value = $value->all(); + } elseif ($value instanceof FluentTag) { + return value($value->fetch()); + } elseif ($value instanceof Modify) { + return value($value->fetch()); + } + + return $value; +} + +function modify(mixed $value): Modify +{ + return Statamic::modify($value); +} + +function tag(string $name): FluentTag +{ + return Statamic::tag($name); +} + +function void(): string +{ + return 'void::'.GlobalRuntimeState::$environmentId; +} diff --git a/tests/FakesViews.php b/tests/FakesViews.php index 6ecf32c96d..549797093a 100644 --- a/tests/FakesViews.php +++ b/tests/FakesViews.php @@ -2,6 +2,8 @@ namespace Tests; +use Illuminate\Support\Facades\Blade; +use Illuminate\Support\Str; use Illuminate\View\Factory; use Illuminate\View\View; @@ -92,6 +94,10 @@ public function get($path, array $data = []) return $this->renderedContents[$path]; } + if (Str::endsWith($path, '.blade.php')) { + return Blade::render($this->rawContents[$path], $data); + } + return parent::get($path, $data); } diff --git a/tests/Fields/ArrayableStringTest.php b/tests/Fields/ArrayableStringTest.php index 2207a7eb92..c9639f85c7 100644 --- a/tests/Fields/ArrayableStringTest.php +++ b/tests/Fields/ArrayableStringTest.php @@ -55,4 +55,15 @@ public function it_converts_to_bool() $this->assertTrue((new ArrayableString(4))->toBool()); $this->assertFalse((new ArrayableString(''))->toBool()); } + + #[Test] + public function it_uses_array_access() + { + $val = new ArrayableString('foo', ['one' => 'a', 'two' => 'b']); + + $this->assertTrue(isset($val['one'])); + $this->assertFalse(isset($val['three'])); + $this->assertEquals('a', $val['one']); + $this->assertEquals('nope', $val['three'] ?? 'nope'); + } } diff --git a/tests/Fields/ValueTest.php b/tests/Fields/ValueTest.php index 5a3e128c6f..bd88402554 100644 --- a/tests/Fields/ValueTest.php +++ b/tests/Fields/ValueTest.php @@ -8,6 +8,7 @@ use Statamic\Fields\Field; use Statamic\Fields\Fieldtype; use Statamic\Fields\Value; +use Statamic\Query\Builder; use Tests\TestCase; class ValueTest extends TestCase @@ -213,6 +214,128 @@ public function augment($value) $this->assertSame(false, $value->value()); }); } + + #[Test] + public function it_uses_array_access_with_string() + { + $val = new Value('foo'); + + $this->assertFalse(isset($val['something'])); + $this->assertEquals('nope', $val['something'] ?? 'nope'); + } + + #[Test] + public function it_uses_array_access_with_array() + { + $val = new Value([ + 'a' => 'alfa', + 'b' => 'bravo', + ]); + + $this->assertTrue(isset($val['a'])); + $this->assertFalse(isset($val['c'])); + $this->assertEquals('alfa', $val['a'] ?? 'nope'); + $this->assertEquals('nope', $val['c'] ?? 'nope'); + } + + #[Test] + public function it_can_iterate_over_array() + { + $val = new Value([ + 'a' => 'alfa', + 'b' => 'bravo', + ]); + + $arr = []; + + foreach ($val as $key => $value) { + $arr[$key] = $value; + } + + $this->assertEquals([ + 'a' => 'alfa', + 'b' => 'bravo', + ], $arr); + } + + #[Test] + public function it_can_iterate_over_collection() + { + $val = new Value(collect([ + 'a' => 'alfa', + 'b' => 'bravo', + ])); + + $arr = []; + + foreach ($val as $key => $value) { + $arr[$key] = $value; + } + + $this->assertEquals([ + 'a' => 'alfa', + 'b' => 'bravo', + ], $arr); + } + + #[Test] + public function it_can_iterate_over_query_builder() + { + $builder = \Mockery::mock(Builder::class); + $builder->shouldReceive('get')->andReturn(collect([ + 'a' => 'alfa', + 'b' => 'bravo', + ])); + + $val = new Value($builder); + + $arr = []; + + foreach ($val as $key => $value) { + $arr[$key] = $value; + } + + $this->assertEquals([ + 'a' => 'alfa', + 'b' => 'bravo', + ], $arr); + } + + #[Test] + public function it_can_proxy_methods_to_value() + { + // This is useful when the value is an object like an Entry, you could + // do $value->slug(). Or for a LabeledValue you could do $value->label(). + + $object = new class + { + public function bar() + { + return 'foo'; + } + }; + + $value = new Value($object); + + $this->assertEquals('foo', $value->bar()); + } + + #[Test] + public function it_can_proxy_property_access_to_value() + { + // This is useful when the value is an object like an Entry, you could + // do $value->slug. + + $object = new class + { + public $bar = 'foo'; + }; + + $value = new Value($object); + + $this->assertEquals('foo', $value->bar); + $this->assertEquals('nope', $value->baz ?? 'nope'); + } } class DummyAugmentable implements \Statamic\Contracts\Data\Augmentable diff --git a/tests/Tags/Dictionary/DictionaryItemTest.php b/tests/Tags/Dictionary/DictionaryItemTest.php new file mode 100644 index 0000000000..3c537b1a00 --- /dev/null +++ b/tests/Tags/Dictionary/DictionaryItemTest.php @@ -0,0 +1,35 @@ + 'alfa', + 'b' => 'bravo', + ]); + + $this->assertTrue(isset($val['a'])); + $this->assertFalse(isset($val['c'])); + $this->assertEquals('alfa', $val['a'] ?? 'nope'); + $this->assertEquals('nope', $val['c'] ?? 'nope'); + } + + #[Test] + public function it_can_proxy_property_access_to_value() + { + $value = new DictionaryItem([ + 'a' => 'alfa', + ]); + + $this->assertEquals('alfa', $value->a); + $this->assertEquals('nope', $value->b ?? 'nope'); + } +} diff --git a/tests/Tags/DictionaryTagTest.php b/tests/Tags/Dictionary/DictionaryTagTest.php similarity index 98% rename from tests/Tags/DictionaryTagTest.php rename to tests/Tags/Dictionary/DictionaryTagTest.php index f717da2792..5270ac82bb 100644 --- a/tests/Tags/DictionaryTagTest.php +++ b/tests/Tags/Dictionary/DictionaryTagTest.php @@ -1,6 +1,6 @@ viewShouldReturnRaw('template', file_get_contents(__DIR__.'/fixtures/template.antlers.html'), 'blade.php'); + $this->viewShouldReturnRaw('template', 'Template: {{ $foo }}', 'blade.php'); $this->viewShouldReturnRaw('layout', file_get_contents(__DIR__.'/fixtures/layout.antlers.html')); $view = (new View) diff --git a/tests/View/Blade/AntlersComponents/ComponentCompilerTest.php b/tests/View/Blade/AntlersComponents/ComponentCompilerTest.php new file mode 100644 index 0000000000..e4a6f7dd00 --- /dev/null +++ b/tests/View/Blade/AntlersComponents/ComponentCompilerTest.php @@ -0,0 +1,402 @@ +artisan('view:clear'); + $this->makeTestData(); + } + + protected function makeTestData() + { + Collection::make('blog')->routes(['en' => '{slug}'])->save(); + EntryFactory::collection('blog')->id('1')->data(['title' => 'One'])->create(); + EntryFactory::collection('blog')->id('2')->data(['title' => 'Two'])->create(); + EntryFactory::collection('blog')->id('3')->data(['title' => 'Three'])->create(); + EntryFactory::collection('blog')->id('4')->data(['title' => 'Four'])->create(); + } + + #[Test] + public function it_extracts_variables_inside_loops() + { + $template = <<<'BLADE' +{{ $title }} +BLADE; + + $this->assertSame('OneTwoThreeFour', Blade::render($template)); + } + + #[Test] + public function it_injects_loop_variable() + { + $template = <<<'BLADE' + + @if ($loop->first) + The First: {{ $title }} | + @else + {{ $title }}{{ $loop->last ? '' : ' |' }} + @endif + +BLADE; + + $this->assertSame( + 'The First: One | Two | Three | Four', + Str::squish(Blade::render($template)) + ); + } + + #[Test] + public function it_applies_scope() + { + $template = <<<'BLADE' +{{ $entry->title }} +BLADE; + + $this->assertSame('FourThreeTwoOne', Blade::render($template)); + + $template = <<<'BLADE' +{{ $entry->title }} +BLADE; + + $this->assertSame('FourThreeTwoOne', Blade::render($template)); + + // I see you. + $template = <<<'BLADE' +{{ $entry->title }} +BLADE; + + $this->assertSame('FourThreeTwoOne', Blade::render($template)); + } + + #[Test] + public function it_does_not_leak_data() + { + $template = <<<'BLADE' +{{ $title }}|{{ $title }}|{{ $title }} +BLADE; + + $this->assertSame('The Title!|OneTwoThreeFour|The Title!', Blade::render($template, ['title' => 'The Title!'])); + } + + #[Test] + public function it_does_not_allow_modifications_to_the_page_variable_from_to_persist() + { + $template = <<<'BLADE' +{{ $page }}|{{ $page }}|{{ $page }} +BLADE; + + $this->assertSame( + 'Running with Scissors|hello, world!|Running with Scissors', + Blade::render($template, ['page' => 'Running with Scissors']) + ); + } + + #[Test] + public function it_compiles_nested_tags() + { + $template = <<<'BLADE' + + Before: + There are {{ count($posts) }} posts. + + + There are {{ count($posts) }} posts. + @foreach ($posts as $post) {{ $post->title }} @endforeach + + After: + + {{-- The original $posts array should be restored. --}} + There are {{ count($posts) }} posts. + @foreach ($posts as $post) {{ $post->title }} @endforeach + + +BLADE; + + $this->assertSame( + 'Before: There are 4 posts. There are 2 posts. Four One After: There are 4 posts. Two Three One Four', + Str::squish(Blade::render($template)) + ); + } + + #[Test] + public function it_compiles_self_closing_tags() + { + $template = <<<'BLADE' + +BLADE; + + $this->assertSame( + '4', + Blade::render($template) + ); + } + + #[Test] + public function it_compiles_shorthand_variable_parameters() + { + $template = <<<'BLADE' +{{ $title }} +BLADE; + + $this->assertSame( + 'OneTwoThreeFour', + Blade::render($template, ['from' => 'blog']) + ); + } + + #[Test] + public function test_it_compiles_escaped_parameters() + { + (new class extends Tags + { + use RendersAttributes; + + protected static $handle = 'test'; + + public function index() + { + $params = $this->renderAttributesFromParams(except: ['src']); + + return $params.'|'.$this->params->get('src'); + } + })::register(); + + // Internally Blade's escaped param syntax will + // be converted to the attr:src="$test" form + // that existing Tags implementations use + $template = <<<'BLADE' + +BLADE; + + $this->assertSame( + ':src="$test"|the test', + Blade::render($template, ['test' => 'the test']) + ); + } + + #[Test] + public function it_compiles_interpolated_parameters() + { + $template = <<<'BLADE' +{{ $title }} +BLADE; + + $this->assertSame( + 'OneTwoThreeFour', + Blade::render($template, ['from' => 'blog']) + ); + } + + #[Test] + public function it_can_alias_results() + { + $template = <<<'BLADE' + + @if (! isset($title)) Should not have a title @endif + @foreach ($entries as $entry) + {{ $entry->title }} + @endforeach + +BLADE; + + $result = Str::squish(Blade::render($template)); + $this->assertSame('Should not have a title One Two Three Four', $result); + + $template = <<<'BLADE' + + @if (! isset($title)) Should not have a title @endif + @foreach ($entries as $entry) + {{ $entry->title }} + @endforeach + +BLADE; + + $result = Str::squish(Blade::render($template)); + $this->assertSame('Should not have a title One Two Three Four', $result); + } + + #[Test] + public function it_allows_variables_to_be_updated() + { + $template = <<<'BLADE' + + + @foreach ($entries as $entry) + + @endforeach + +{{ $myFancyCounter }} +BLADE; + + $this->assertSame('4', trim(Blade::render($template))); + } + + #[Test] + public function it_passes_full_tag_name() + { + (new class extends Tags + { + protected static $handle = 'my_tag'; + + public function wildcard() + { + return $this->tag; + } + })::register(); + + $this->assertSame( + 'my_tag:the_method', + Blade::render(''), + ); + } + + #[Test] + public function it_compiles_no_results() + { + (new class extends Tags + { + protected static $handle = 'my_tag'; + + public function index() + { + return $this->context['the_array']; + } + })::register(); + + $template = <<<'BLADE' + + {{ $value }} + + + {{ $title }} Nothing to see here! + + +BLADE; + + $this->assertSame( + 'a b c', + Str::squish(Blade::render($template, ['the_array' => ['a', 'b', 'c']])), + ); + + $this->assertSame( + 'Hello! Nothing to see here!', + Str::squish(Blade::render($template, ['the_array' => [], 'title' => 'Hello!'])), + ); + } + + #[Test] + public function it_detects_blade_vs_antlers() + { + (new class extends Tags + { + protected static $handle = 'my_tag'; + + public function index() + { + if ($this->isAntlersBladeComponent()) { + return 'Hello, Blade!'; + } + + return 'Hello, Antlers!'; + } + })::register(); + + $this->assertSame( + 'Hello, Blade!', + Blade::render(''), + ); + + $this->assertSame( + 'Hello, Antlers!', + (string) Antlers::parse('{{ my_tag }}'), + ); + } + + #[Test] + public function it_supports_void_params() + { + (new class extends Tags + { + protected static $handle = 'my_tag'; + + public function index() + { + if ($this->params->has('the_param')) { + return 'It does!'; + } + + return 'It does not.'; + } + })::register(); + + $template = <<<'BLADE' +@php + use function \Statamic\View\Blade\{void}; +@endphp + +BLADE; + + $this->assertSame( + 'It does!', + Blade::render($template, ['do_include' => true]), + ); + + $this->assertSame( + 'It does not.', + Blade::render($template, ['do_include' => false]), + ); + } + + #[Test] + public function it_correctly_compiles_nested_self_closing_tags() + { + (new class extends Tags + { + protected static $handle = 'my_tag'; + + public function index() + { + if (! $this->isPair) { + return 'Just a self-closing tag.'; + } + + return [['title' => 'One'], ['title' => 'Two']]; + } + })::register(); + + $template = <<<'BLADE' +| + +{{ $title }} +| + +BLADE; + + $this->assertSame( + 'Just a self-closing tag.| One Just a self-closing tag.| Two Just a self-closing tag.|', + Str::squish(Blade::render($template)), + ); + } +} diff --git a/tests/View/Blade/AntlersComponents/NavCompilerTest.php b/tests/View/Blade/AntlersComponents/NavCompilerTest.php new file mode 100644 index 0000000000..7ad021026a --- /dev/null +++ b/tests/View/Blade/AntlersComponents/NavCompilerTest.php @@ -0,0 +1,235 @@ +artisan('view:clear'); + $this->makeNavTree(); + } + + private function makeNavTree() + { + $tree = [ + ['id' => 'home', 'title' => 'Home', 'url' => '/'], + [ + 'id' => 'about', 'title' => 'About', 'url' => 'about', + 'children' => [ + ['id' => 'team', 'title' => 'Team', 'url' => 'team'], + ['id' => 'leadership', 'title' => 'Leadership', 'url' => 'leadership'], + ], + ], + [ + 'id' => 'projects', 'title' => 'Projects', 'url' => 'projects', + 'children' => [ + ['id' => 'project-1', 'title' => 'Project-1', 'url' => 'project-1'], + [ + 'id' => 'project-2', 'title' => 'Project-2', 'url' => 'project-2', + 'children' => [ + ['id' => 'project-2-nested', 'title' => 'Project 2 Nested', 'url' => 'project-2-nested'], + ], + ], + ], + ], + ['id' => 'contact', 'title' => 'Contact', 'url' => 'contact'], + ]; + + $nav = Nav::make('main'); + $nav->makeTree('en', $tree)->save(); + $nav->save(); + } + + #[Test] + public function it_renders_simple_navs() + { + $template = <<<'BLADE' +
    + +
  • {{ $title }}
  • +
    +
+BLADE; + + $expected = <<<'EXPECTED' +
    +
  • Home
  • +
  • About
  • +
  • Projects
  • +
  • Contact
  • +
+EXPECTED; + + $this->assertSame( + $expected, + Blade::render($template) + ); + } + + #[Test] + public function it_renders_simple_recursive_children() + { + $template = <<<'BLADE' +
    + +
  • + {{ $title }} - {{ $depth }} + + @if (count($children) > 0) +
      + @recursive_children +
    + @endif +
  • +
    +
+BLADE; + + $expected = <<<'EXPECTED' +
    +
  • + Home - 1 + +
  • +
  • + About - 1 + +
      +
    • + Team - 2 + +
    • +
    • + Leadership - 2 + +
    • +
    +
  • +
  • + Projects - 1 + +
      +
    • + Project-1 - 2 + +
    • +
    • + Project-2 - 2 + +
        +
      • + Project 2 Nested - 3 + +
      • +
      +
    • +
    +
  • +
  • + Contact - 1 + +
  • +
+EXPECTED; + + $this->assertSame( + $expected, + Blade::render($template) + ); + } + + #[Test] + public function it_renders_aliased_navs() + { + $template = <<<'BLADE' +
    + +@foreach ($the_items as $item) +
  • {{ $item['title'] }}
  • +@endforeach +
    +
+BLADE; + + $expected = <<<'EXPECTED' +
    +
  • Home
  • +
  • About
  • +
  • Projects
  • +
  • Contact
  • +
+EXPECTED; + + $this->assertSame( + $expected, + Blade::render($template) + ); + } + + #[Test] + public function it_renders_aliased_recursive_children() + { + $template = <<<'BLADE' +
    + +@foreach ($the_items as $item) +
  • {{ $item['title'] }} - {{ $item['depth'] }}
  • + +@if (isset($item['children']) && count($item['children'])) +
      +@recursive_children($item['children']) +
    +@endif +@endforeach +
    +
+BLADE; + + $expected = <<<'EXPECTED' +
    +
  • Home - 1
  • + +
  • About - 1
  • + +
      +
    • Team - 2
    • + +
    • Leadership - 2
    • + +
    +
  • Projects - 1
  • + +
      +
    • Project-1 - 2
    • + +
    • Project-2 - 2
    • + +
        +
      • Project 2 Nested - 3
      • + +
      +
    +
  • Contact - 1
  • + +
+EXPECTED; + + $this->assertSame( + $expected, + Blade::render($template) + ); + } +} diff --git a/tests/View/Blade/AntlersComponents/PartialCompilerTest.php b/tests/View/Blade/AntlersComponents/PartialCompilerTest.php new file mode 100644 index 0000000000..983b1a927f --- /dev/null +++ b/tests/View/Blade/AntlersComponents/PartialCompilerTest.php @@ -0,0 +1,383 @@ +withFakeViews(); + $this->artisan('view:clear'); + + } + + #[Test] + public function it_compiles_partial_tags() + { + $alert = <<<'ALERT' +
{{ $title }}
+ALERT; + $this->viewShouldReturnRaw('alert', $alert, 'blade.php'); + + $expected = '
The Title
'; + + $this->assertSame( + $expected, + Blade::render('', ['title' => 'The Title']) + ); + + $this->assertSame( + $expected, + Blade::render('', ['title' => 'The Title']) + ); + + $expected = '
Custom Title
'; + + $this->assertSame( + $expected, + Blade::render('', ['title' => 'The Title']) + ); + + $this->assertSame( + $expected, + Blade::render('', ['title' => 'The Title']) + ); + } + + #[Test] + public function it_compiles_slots() + { + $alert = <<<'ALERT' +
{{ $slot }}
+ALERT; + $this->viewShouldReturnRaw('alert', $alert); + + $template = <<<'BLADE' + + I am the slot content. + +BLADE; + + $this->assertSame( + '
I am the slot content.
', + Blade::render($template) + ); + } + + #[Test] + public function it_compiles_named_slots() + { + $alert = <<<'ALERT' + +
{{ $slot }}
+ +ALERT; + $this->viewShouldReturnRaw('alert', $alert); + + $template = <<<'BLADE' + + The header + The footer + I am the slot content. + +BLADE; + + $expected = <<<'EXPECTED' + +
I am the slot content.
+ +EXPECTED; + + $this->assertSame( + $expected, + Blade::render($template) + ); + } + + #[Test] + public function it_forwards_exists_method_calls() + { + $template = <<<'TEMPLATE' +Yes +TEMPLATE; + + $this->assertSame('', Blade::render($template)); + + $this->viewShouldReturnRaw('alert', 'some content'); + + $this->assertSame('Yes', Blade::render($template)); + } + + #[Test] + public function it_forwards_if_exists_method_calls() + { + $template = <<<'TEMPLATE' + +TEMPLATE; + + $this->assertSame('', Blade::render($template)); + + $this->viewShouldReturnRaw('alert', 'some content'); + + $this->assertSame('some content', Blade::render($template)); + } + + #[Test] + public function it_compiles_when_parameter() + { + $this->viewShouldReturnRaw('the_partial', 'The content'); + + $template = <<<'TEMPLATE' + +TEMPLATE; + + $this->assertSame('', Blade::render($template, ['theValue' => false])); + $this->assertSame('The content', Blade::render($template, ['theValue' => true])); + } + + #[Test] + public function it_compiles_unless_parameter() + { + $this->viewShouldReturnRaw('the_partial', 'The content'); + + $template = <<<'TEMPLATE' + +TEMPLATE; + + $this->assertSame('', Blade::render($template, ['theValue' => true])); + $this->assertSame('The content', Blade::render($template, ['theValue' => false])); + } + + #[Test] + public function it_compiles_conditional_parameters_with_slots() + { + $alert = <<<'ALERT' + +
{{ $slot }}
+ +ALERT; + $this->viewShouldReturnRaw('alert', $alert); + + $template = <<<'BLADE' + + The header + The footer + I am the slot content. + +BLADE; + + $expected = <<<'EXPECTED' + +
I am the slot content.
+ +EXPECTED; + + $this->assertSame('', Blade::render($template, ['theValue' => false])); + $this->assertSame($expected, Blade::render($template, ['theValue' => true])); + } + + #[Test] + public function it_compiles_nested_partials() + { + $alert = <<<'ANTLERS' + +
{{ slot }}
+ +ANTLERS; + + $this->viewShouldReturnRaw('alert', $alert); + + $template = <<<'BLADE' + + The header + The footer + I am the slot content. + + + The header + The footer2 + I am the second slot content. + + +BLADE; + + $expected = <<<'EXPECTED' + +
I am the slot content. + + +
I am the second slot content.
+
+ +EXPECTED; + + $this->assertSame( + $expected, + Blade::render($template) + ); + } + + #[Test] + public function it_merges_frontmatter_using_directive() + { + $this->withFakeViews(); + + $partial = <<<'BLADE' +@frontmatter([ + 'name' => 'The Name!', + 'image' => 'https://example.com/placeholder.png', +]) + +Without view: {{ $name ?? '' }} {{ $image ?? '' }} | +With view: {{ $view['name'] }} {{ $view['image'] }} +BLADE; + + $this->viewShouldReturnRaw('the_partial', $partial, 'blade.php'); + + $this->assertSame( + 'Without view: | With view: The Name! https://example.com/placeholder.png', + Str::squish(Blade::render('')) + ); + + $this->assertSame( + 'Without view: A different name! | With view: A different name! https://example.com/placeholder.png', + Str::squish(Blade::render('')) + ); + } + + #[Test] + public function frontmatter_populates_view_array() + { + $this->withFakeViews(); + + $partial = <<<'BLADE' +@frontmatter([ + 'name' => 'The Name!', + 'image' => 'https://example.com/placeholder.png', +]) + +{{ $view['name'] }} {{ $view['image'] }} +BLADE; + + $this->viewShouldReturnRaw('the_partial', $partial, 'blade.php'); + + $this->assertSame( + 'The Name! https://example.com/placeholder.png', + Blade::render('') + ); + + $this->assertSame( + 'A different name! https://example.com/placeholder.png', + Blade::render('') + ); + } + + #[Test] + public function it_compiles_nested_self_closing_partial_tags() + { + $this->withFakeViews(); + + $this->viewShouldReturnRaw('one', 'Just Some Text', 'blade.php'); + + $this->viewShouldReturnRaw('two', '{{ $slot }}', 'blade.php'); + + $template = <<<'BLADE' + +| + +Some More Text +| + + | After Nested Partial Call + +BLADE; + + $this->assertSame( + 'Just Some Text| Some More Text | Just Some Text | After Nested Partial Call', + Str::squish(Blade::render($template)) + ); + + $template = <<<'BLADE' + +| +Partial Two Call One + +Some More Text +| + + | After Nested Partial Call + | Partial Two Call Two + | + +Some Even More Text +| + + | After Another Nested Partial Call + + +BLADE; + + $this->assertSame( + 'Just Some Text| Partial Two Call One Some More Text | Just Some Text | After Nested Partial Call | Partial Two Call Two | Some Even More Text | Just Some Text | After Another Nested Partial Call', + Str::squish(Blade::render($template)) + ); + } + + #[Test] + public function slot_content_does_not_need_to_be_manually_escaped() + { + $this->withFakeViews(); + + $partial = <<<'BLADE' +Partial Start {{ $slot }} Partial End +BLADE; + + $this->viewShouldReturnRaw('the_partial', $partial, 'blade.php'); + + $template = <<<'BLADE' + +I am the slot content! + +BLADE; + + $this->assertSame( + 'Partial Start I am the slot content! Partial End', + Blade::render($template), + ); + $partial = <<<'BLADE' +Header Start {{ $header }} Header End +Partial Start {{ $slot }} Partial End +BLADE; + + $this->viewShouldReturnRaw('the_partial', $partial, 'blade.php'); + + $template = <<<'BLADE' + +I am the header! +I am the slot content! + +BLADE; + + $expected = <<<'EXPECTED' +Header Start I am the header! Header End +Partial Start I am the slot content! Partial End +EXPECTED; + + $this->assertSame( + $expected, + Blade::render($template), + ); + } +} diff --git a/tests/View/Blade/AntlersComponents/ReturnValuesTest.php b/tests/View/Blade/AntlersComponents/ReturnValuesTest.php new file mode 100644 index 0000000000..2452f5d190 --- /dev/null +++ b/tests/View/Blade/AntlersComponents/ReturnValuesTest.php @@ -0,0 +1,177 @@ +artisan('view:clear'); + } + + #[Test] + public function it_renders_arrays() + { + (new class extends Tags + { + protected static $handle = 'my_tag'; + + public function index() + { + return ['a', 'b', 'c']; + } + })::register(); + + $template = <<<'BLADE' + + {{ $value }} + +BLADE; + + $this->assertSame( + 'a b c', + Str::squish(Blade::render($template)) + ); + } + + #[Test] + public function it_renders_arrays_of_arrays() + { + (new class extends Tags + { + protected static $handle = 'my_tag'; + + public function index() + { + return [ + ['name' => 'Alice'], + ['name' => 'Bob'], + ['name' => 'Charlie'], + ]; + } + })::register(); + + $template = <<<'BLADE' + + {{ $name }} + +BLADE; + + $this->assertSame( + 'Alice Bob Charlie', + Str::squish(Blade::render($template)) + ); + } + + #[Test] + public function it_renders_collections() + { + (new class extends Tags + { + protected static $handle = 'my_tag'; + + public function index() + { + return collect(['a', 'b', 'c']); + } + })::register(); + + $template = <<<'BLADE' + + {{ $value }} + +BLADE; + + $this->assertSame( + 'a b c', + Str::squish(Blade::render($template)) + ); + } + + #[Test] + public function it_renders_collections_of_arrays() + { + (new class extends Tags + { + protected static $handle = 'my_tag'; + + public function index() + { + return collect([ + ['name' => 'Alice'], + ['name' => 'Bob'], + ['name' => 'Charlie'], + ]); + } + })::register(); + + $template = <<<'BLADE' + + {{ $name }} + +BLADE; + + $this->assertSame( + 'Alice Bob Charlie', + Str::squish(Blade::render($template)) + ); + } + + #[Test] + public function it_conditionally_renders_content_based_on_boolean_results() + { + (new class extends Tags + { + protected static $handle = 'my_tag'; + + public function index() + { + return $this->params->get('value', true); + } + })::register(); + + $this->assertSame( + 'Yes', + Blade::render('Yes') + ); + + $this->assertSame( + '', + Blade::render('') + ); + } + + #[Test] + public function it_renders_string_results() + { + (new class extends Tags + { + protected static $handle = 'my_tag'; + + public function index() + { + return 'Hi!'; + } + })::register(); + + $this->assertSame( + 'Hi!', + Blade::render('') + ); + + $this->assertSame( + 'Hi!', + Blade::render('') + ); + } +} diff --git a/tests/View/Blade/AntlersComponents/ScopeTagTest.php b/tests/View/Blade/AntlersComponents/ScopeTagTest.php new file mode 100644 index 0000000000..74f258a9d3 --- /dev/null +++ b/tests/View/Blade/AntlersComponents/ScopeTagTest.php @@ -0,0 +1,30 @@ + + @php($title = 'A different title') + {{ $stuff['title'] }} + {{ $title }} + +BLADE; + + $this->assertSame( + 'The Title A different title', + Str::squish(Blade::render($template, ['title' => 'The Title'])), + ); + } +} diff --git a/tests/View/Blade/AntlersComponents/SelfClosingTagsTest.php b/tests/View/Blade/AntlersComponents/SelfClosingTagsTest.php new file mode 100644 index 0000000000..c3d9985326 --- /dev/null +++ b/tests/View/Blade/AntlersComponents/SelfClosingTagsTest.php @@ -0,0 +1,54 @@ +artisan('view:clear'); + } + + #[Test] + public function it_correctly_sets_is_pair() + { + (new class extends Tags + { + protected static $handle = 'my_tag'; + + public function index() + { + if ($this->isPair) { + return 'Definitely a tag pair.'; + } + + return 'I am not a tag pair!'; + } + })::register(); + + $this->assertSame( + 'Definitely a tag pair.', + Blade::render('') + ); + + $this->assertSame( + 'I am not a tag pair!', + Blade::render('') + ); + + // Please use self-closing tags, though. 🙏 + $this->assertSame( + 'I am not a tag pair!Definitely a tag pair.', + Blade::render('') + ); + } +} diff --git a/tests/View/Blade/AntlersComponents/TagContentsTest.php b/tests/View/Blade/AntlersComponents/TagContentsTest.php new file mode 100644 index 0000000000..b24e88eeb4 --- /dev/null +++ b/tests/View/Blade/AntlersComponents/TagContentsTest.php @@ -0,0 +1,41 @@ +params->get('swap', false)) { + $this->content = 'No Swiping!'; + } + + return $this->parse(); + } + })::register(); + + $this->assertSame( + 'Original Stuff.', + Blade::render('Original Stuff.') + ); + + $this->assertSame( + 'No Swiping!', + Blade::render('Original Stuff.') + ); + } +}