diff --git a/src/Providers/ExtensionServiceProvider.php b/src/Providers/ExtensionServiceProvider.php index 5371de78bcf..b237ba4ae0d 100644 --- a/src/Providers/ExtensionServiceProvider.php +++ b/src/Providers/ExtensionServiceProvider.php @@ -185,6 +185,7 @@ class ExtensionServiceProvider extends ServiceProvider Tags\GetSite::class, Tags\Glide::class, Tags\In::class, + Tags\IncludeTag::class, Tags\Increment::class, Tags\Installed::class, Tags\Is::class, diff --git a/src/Providers/ViewServiceProvider.php b/src/Providers/ViewServiceProvider.php index a5661315580..2ebcba95062 100644 --- a/src/Providers/ViewServiceProvider.php +++ b/src/Providers/ViewServiceProvider.php @@ -10,6 +10,8 @@ use Statamic\Contracts\View\Antlers\Parser as ParserContract; use Statamic\Facades\Site; use Statamic\Statamic; +use Statamic\StaticCaching\NoCache\Region; +use Statamic\Tags\IncludeTag; use Statamic\View\Antlers\Engine; use Statamic\View\Antlers\Language\Analyzers\NodeTypeAnalyzer; use Statamic\View\Antlers\Language\Runtime\Debugging\GlobalDebugManager; @@ -425,6 +427,8 @@ public function boot() { ViewFactory::addNamespace('compiled__views', storage_path('framework/views')); + Region::preserveContextKeys(IncludeTag::VIEW_DATA_KEYS); + $this->registerBladeDirectives(); Blade::precompiler(function ($content) { diff --git a/src/StaticCaching/NoCache/Region.php b/src/StaticCaching/NoCache/Region.php index 257004d461d..b086fdb95ba 100644 --- a/src/StaticCaching/NoCache/Region.php +++ b/src/StaticCaching/NoCache/Region.php @@ -11,6 +11,13 @@ abstract class Region protected $context = []; protected $session; + protected static $preservedContextKeys = []; + + public static function preserveContextKeys(array $keys): void + { + static::$preservedContextKeys = array_unique(array_merge(static::$preservedContextKeys, $keys)); + } + public function setSession(Session $session) { $this->session = $session; @@ -26,21 +33,18 @@ public function context(): array return $this->context; } - protected function filterContext(array $context) + public static function filterCacheable(array $context): array { - $context = collect($context) - ->reject(fn ($value, $key) => str_starts_with((string) $key, '__')) - ->reject(fn ($value, $key) => in_array($key, ['app', 'errors', 'resolve', 'resolveComponentsUsing', 'forgetComponentsResolver', 'forgetFactory', 'flushCache', 'constructor'])) - ->map(function ($value, $key) { - if ($value instanceof InvokableComponentVariable) { - return $value->resolveDisplayableValue(); - } - - return $value; - }) + return collect($context) + ->reject(fn ($value, $key) => str_starts_with((string) $key, '__') && ! in_array($key, static::$preservedContextKeys)) + ->reject(fn ($value, $key) => in_array($key, ['app', 'errors', 'obLevel', 'resolve', 'resolveComponentsUsing', 'forgetComponentsResolver', 'forgetFactory', 'flushCache', 'constructor'])) + ->map(fn ($value) => $value instanceof InvokableComponentVariable ? $value->resolveDisplayableValue() : $value) ->all(); + } - return $this->arrayRecursiveDiff($context, $this->session->cascade()); + protected function filterContext(array $context) + { + return $this->arrayRecursiveDiff(static::filterCacheable($context), $this->session->cascade()); } public function fragmentData(): array diff --git a/src/Tags/Concerns/RendersViews.php b/src/Tags/Concerns/RendersViews.php new file mode 100644 index 00000000000..c9b4a20785c --- /dev/null +++ b/src/Tags/Concerns/RendersViews.php @@ -0,0 +1,92 @@ +exists($underscored = $this->underscoredViewName($partial))) { + return $underscored; + } + + if (view()->exists($subdirectoried = 'partials.'.$partial)) { + return $subdirectoried; + } + + if (view()->exists($underscored_subdirectoried = 'partials.'.$this->underscoredViewName($partial))) { + return $underscored_subdirectoried; + } + + return $partial; + } + + protected function underscoredViewName($partial) + { + $bits = collect(explode('.', $partial)); + + $last = $bits->pull($bits->count() - 1); + + return $bits->implode('.').'._'.$last; + } + + protected function shouldRender(): bool + { + if ($this->params->has('when')) { + return $this->params->bool('when'); + } + + if ($this->params->has('unless')) { + return ! $this->params->bool('unless'); + } + + return true; + } + + protected function getSlotContent(): HtmlString|string + { + $content = trim($this->parse()); + + if ($this->isAntlersBladeComponent()) { + return new HtmlString($content); + } + + return $content; + } + + /** + * The {{ exists }} tag. + * + * Returns true if the view exists, false otherwise. If the src parameter is + * omitted, it acts like the user is trying to use a view named "exists". + */ + public function exists() + { + if (! $view = $this->params->get('src')) { + return $this->wildcard('exists'); + } + + return view()->exists($this->viewName($view)); + } + + /** + * The {{ if_exists }} tag. + * + * Renders the view if it exists, and outputs nothing otherwise. If the src parameter + * is omitted, it acts like the user is trying to use a view named "if_exists". + */ + public function ifExists() + { + if (! $view = $this->params->get('src')) { + return $this->wildcard('if_exists'); + } + + if (view()->exists($this->viewName($view))) { + return $this->render($view); + } + } +} diff --git a/src/Tags/IncludeTag.php b/src/Tags/IncludeTag.php new file mode 100644 index 00000000000..f5eeae912b2 --- /dev/null +++ b/src/Tags/IncludeTag.php @@ -0,0 +1,299 @@ +params->get('src', $tag); + + if (! $view) { + throw new RuntimeException('The include tag requires a view name or the [src] parameter.'); + } + + return $this->render($view); + } + + protected function render($view) + { + $parameters = $this->params->all(); + $spread = $this->unwrap($parameters['params'] ?? null); + $prefixes = $this->unwrap($parameters['handle_prefix'] ?? null); + + $this->validateReserved($parameters, $spread, $prefixes); + + if (! $this->shouldRender()) { + return ''; + } + + $data = $this->resolveData($parameters, $this->spread($spread), $prefixes); + $view = view($this->viewName($view)); + $isBlade = ! Str::endsWith($view->getPath(), Engine::EXTENSIONS); + + $cascade = Cascade::toArray(); + + $scope = array_merge( + $this->params->bool('cascade') ? $cascade : [], + $data, + $this->resolveSlots($parameters, $data, $isBlade), + [ + 'params' => $data, + '__frontmatter' => $data, + ], + $isBlade ? [self::CONTEXT_KEY => true] : [] + ); + + $hadViews = array_key_exists('views', $cascade); + $viewsState = $cascade['views'] ?? null; + + // Suspended here rather than in the runtime's isolation so Blade-invoked includes are + // isolated too. Other isolated tags inheriting handle prefixes is technically + // unintentional, but preserved for BC. This may change in the next major version. + $suspendedCascade = GlobalRuntimeState::$isCascadeEnabled; + $suspendedPrefixes = GlobalRuntimeState::$prefixState; + + GlobalRuntimeState::$isCascadeEnabled = false; + GlobalRuntimeState::$prefixState = []; + GlobalRuntimeState::$isolateViewData = true; + + try { + return $view->with($scope) + ->withoutExtractions() + ->render(); + } finally { + GlobalRuntimeState::$isolateViewData = false; + GlobalRuntimeState::$isCascadeEnabled = $suspendedCascade; + GlobalRuntimeState::$prefixState = $suspendedPrefixes; + + if ($hadViews) { + Cascade::set('views', $viewsState); + } elseif (Cascade::get('views') !== null) { + Cascade::data(Arr::except(Cascade::toArray(), 'views')); + } + } + } + + protected function resolveData(array $parameters, array $spread, mixed $prefixes): array + { + $named = []; + + foreach ($parameters as $key => $value) { + if ($this->isDataParameter($key, $value)) { + $named[$key] = $value; + } + } + + return array_merge( + $spread, + $this->unprefixedAliases($spread, $prefixes), + $named, + $this->unprefixedAliases($named, $prefixes) + ); + } + + protected function resolveSlots(array $parameters, array $data, bool $isBlade): array + { + $slots = []; + + foreach ($parameters as $key => $value) { + if ($this->isSlotParameter($key, $value)) { + $slots[substr($key, strlen(self::SLOT_PARAM_PREFIX))] = $value; + } + } + + if ($this->isolatedContext === null && $this->isPair && ! isset($slots['slot'])) { + $content = $this->getSlotContent(); + + if ((string) $content !== '') { + $slots['slot'] = $content; + } + } + + if (empty($slots)) { + return []; + } + + $normalized = []; + $namedSlots = []; + + foreach ($slots as $name => $slot) { + if ($slot instanceof Slot) { + $slot = (clone $slot)->withParams($data)->named($name === 'slot' ? null : $name); + } + + if ($name === 'slot') { + $normalized['slot'] = $slot; + + continue; + } + + $normalized['slot:'.$name] = $slot; + $namedSlots[$name] = $slot; + + if ($isBlade && $this->canAliasSlot($name, $data)) { + $normalized[$name] = $slot; + } + } + + if ($isBlade) { + $normalized[self::SLOTS_KEY] = $namedSlots; + } + + if (! empty($namedSlots)) { + $normalized[GlobalRuntimeState::createIndicatorVariable( + GlobalRuntimeState::INDICATOR_NAMED_SLOTS_AVAILABLE + )] = true; + } + + return $normalized; + } + + protected function canAliasSlot(string $name, array $data): bool + { + return ! array_key_exists($name, $data) + && ! str_starts_with($name, '__') + && ! in_array($name, self::PROTECTED_ALIASES); + } + + protected function isDataParameter(int|string $key, mixed $value): bool + { + return ! in_array($key, self::CONTROL) + && ! in_array($key, self::RESERVED) + && ! $this->isSlotParameter($key, $value); + } + + protected function isSlotParameter(int|string $key, mixed $value): bool + { + return $this->hasSlotPrefix($key) && $value instanceof Slot; + } + + protected function hasSlotPrefix(int|string $key): bool + { + return is_string($key) && str_starts_with($key, self::SLOT_PARAM_PREFIX); + } + + protected function isPrefixedKey(int|string $key, string $prefix): bool + { + return is_string($key) && str_starts_with($key, $prefix) && strlen($key) > strlen($prefix); + } + + protected function spread(mixed $spread): array + { + if ($spread === null) { + return []; + } + + if (! is_array($spread) || (! empty($spread) && ! Arr::isAssoc($spread))) { + throw new RuntimeException('The [params] parameter on the include tag must be an associative array.'); + } + + return $spread; + } + + protected function unprefixedAliases(array $data, mixed $prefixes): array + { + $aliases = []; + + foreach (array_reverse(Arr::wrap($prefixes)) as $prefix) { + if (! is_string($prefix) || $prefix === '') { + continue; + } + + foreach ($data as $key => $value) { + if ($this->isPrefixedKey($key, $prefix)) { + $aliases[substr($key, strlen($prefix))] = $value; + } + } + } + + return $aliases; + } + + protected function validateReserved(array $parameters, mixed $spread, mixed $prefixes): void + { + $this->validateKeys($parameters, allowSlots: true); + + if (! is_array($spread)) { + return; + } + + $this->validateKeys($spread); + $this->validateKeys($this->unprefixedAliases($spread, $prefixes)); + $this->validateKeys($this->unprefixedAliases(Arr::except($parameters, self::CONTROL), $prefixes)); + } + + protected function validateKeys(array $parameters, bool $allowSlots = false): void + { + foreach ($parameters as $key => $value) { + $allowedSlot = $allowSlots && $this->isSlotParameter($key, $value); + + if (in_array($key, self::RESERVED) || ($this->hasSlotPrefix($key) && ! $allowedSlot)) { + throw new RuntimeException("Cannot pass reserved parameter [{$key}] to the include tag."); + } + } + } + + protected function unwrap(mixed $value): mixed + { + if ($value instanceof Value) { + $value = $value->value(); + } + + if ($value instanceof Arrayable) { + $value = $value->toArray(); + } + + return $value; + } +} diff --git a/src/Tags/Partial.php b/src/Tags/Partial.php index f487f31faaa..4c14d3a6dd4 100644 --- a/src/Tags/Partial.php +++ b/src/Tags/Partial.php @@ -2,10 +2,12 @@ namespace Statamic\Tags; -use Illuminate\Support\HtmlString; +use Statamic\Tags\Concerns\RendersViews; class Partial extends Tags { + use RendersViews; + public function wildcard($tag) { // We pass the original non-studly case value in as @@ -21,7 +23,9 @@ protected function render($partial) return; } - $variables = array_merge($this->context->all(), $this->params->all(), [ + $context = array_diff_key($this->context->all(), array_flip(IncludeTag::VIEW_DATA_KEYS)); + + $variables = array_merge($context, $this->params->all(), [ '__frontmatter' => $this->params->all(), 'slot' => $this->isPair ? $this->getSlotContent() : null, ]); @@ -30,88 +34,4 @@ protected function render($partial) ->withoutExtractions() ->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')) { - return $this->params->bool('when'); - } - - if ($this->params->has('unless')) { - return ! $this->params->bool('unless'); - } - - return true; - } - - protected function viewName($partial) - { - $partial = str_replace('/', '.', $partial); - - if (view()->exists($underscored = $this->underscoredViewName($partial))) { - return $underscored; - } - - if (view()->exists($subdirectoried = 'partials.'.$partial)) { - return $subdirectoried; - } - - if (view()->exists($underscored_subdirectoried = 'partials.'.$this->underscoredViewName($partial))) { - return $underscored_subdirectoried; - } - - return $partial; - } - - protected function underscoredViewName($partial) - { - $bits = collect(explode('.', $partial)); - - $last = $bits->pull($bits->count() - 1); - - return $bits->implode('.').'._'.$last; - } - - /** - * The {{ partial:exists }} tag. - * - * Returns true if the partial exists, false otherwise. - * If the src parameter is omitted, it acts like the user is trying to use a partial named "exists". - */ - public function exists() - { - if (! $partial = $this->params->get('src')) { - return $this->wildcard('exists'); - } - - return view()->exists($this->viewName($partial)); - } - - /** - * The {{ partial:if_exists }} tag. - * - * Returns true if the partial exists, false otherwise. - * If the src parameter is omitted, it acts like the user is trying to use a partial named "if_exists". - */ - public function ifExists() - { - if (! $partial = $this->params->get('src')) { - return $this->wildcard('if_exists'); - } - - if (view()->exists($this->viewName($partial))) { - return $this->render($partial); - } - } } diff --git a/src/View/Antlers/Language/Parser/DocumentParser.php b/src/View/Antlers/Language/Parser/DocumentParser.php index 5aff3926646..ee5f47b2adc 100644 --- a/src/View/Antlers/Language/Parser/DocumentParser.php +++ b/src/View/Antlers/Language/Parser/DocumentParser.php @@ -1577,7 +1577,7 @@ public function resetState() /** @var AntlersNode $lastTagNode */ $lastTagNode = GlobalRuntimeState::$globalTagEnterStack[count(GlobalRuntimeState::$globalTagEnterStack) - 1]; - if ($lastTagNode->name->name != 'partial') { + if (! in_array($lastTagNode->name->name, ['partial', 'include'])) { $this->setStartLineSeed($lastTagNode->endPosition->line); } } diff --git a/src/View/Antlers/Language/Runtime/Concerns/ManagesIncludeSlots.php b/src/View/Antlers/Language/Runtime/Concerns/ManagesIncludeSlots.php new file mode 100644 index 00000000000..8ccc814f1ea --- /dev/null +++ b/src/View/Antlers/Language/Runtime/Concerns/ManagesIncludeSlots.php @@ -0,0 +1,109 @@ +buildIncludeSlots($node, $tagActiveData) as $name => $slot) { + $tagParameters[IncludeTag::SLOT_PARAM_PREFIX.$name] = $slot; + } + + return $tagParameters; + } + + protected function buildIncludeSlots(AntlersNode $node, array $callerData): array + { + $namedSlots = []; + $defaultChildren = []; + + foreach ($node->children as $child) { + if ($child instanceof AntlersNode && $child->isClosingTag) { + continue; + } + + if ($this->isNamedSlotNode($child)) { + $namedSlots[$child->name->methodPart] = $child; + + continue; + } + + $defaultChildren[] = $child; + } + + $slots = []; + + if ($this->slotHasContent($defaultChildren)) { + $slots['slot'] = Slot::forAntlers($defaultChildren, $this->defaultSlotSource($node, $namedSlots), $callerData, $this); + } + + foreach ($namedSlots as $slotName => $slotNode) { + if ($this->slotHasContent($slotNode->children)) { + $slots[$slotName] = Slot::forAntlers($slotNode->children, $slotNode->runtimeContent, $callerData, $this); + } + } + + return $slots; + } + + protected function defaultSlotSource(AntlersNode $node, array $namedSlots): string + { + if (empty($namedSlots)) { + return $node->runtimeContent; + } + + $parser = $node->getParser(); + $start = $node->endPosition->index + 1; + $source = ''; + + foreach ($namedSlots as $slotNode) { + $source .= $parser->getText($start, $slotNode->startPosition->index); + $start = ($slotNode->isClosedBy ?? $slotNode)->endPosition->index + 1; + } + + return $source.$parser->getText($start, $node->isClosedBy->startPosition->index); + } + + protected function isNamedSlotNode($node): bool + { + return $node instanceof AntlersNode && ! $node->isComment && + $node->name != null && $node->name->name == 'slot' && + $node->name->methodPart != null; + } + + protected function slotHasContent(array $children): bool + { + foreach ($children as $child) { + if ($child instanceof LiteralNode) { + if (trim($child->content) !== '') { + return true; + } + + continue; + } + + if ($child instanceof AntlersNode && ($child->isComment || $child->isClosingTag)) { + continue; + } + + return true; + } + + return false; + } + + protected function getSlotOutputProps(AntlersNode $node): array + { + $lockData = $this->data; + $props = $node->getParameterValues($this, $this->getActiveData()); + $this->data = $lockData; + + return $props; + } +} diff --git a/src/View/Antlers/Language/Runtime/GlobalRuntimeState.php b/src/View/Antlers/Language/Runtime/GlobalRuntimeState.php index f0a3ddee5f4..b60ce9abb10 100644 --- a/src/View/Antlers/Language/Runtime/GlobalRuntimeState.php +++ b/src/View/Antlers/Language/Runtime/GlobalRuntimeState.php @@ -219,6 +219,11 @@ public static function mergeTagRuntimeAssignments($assignments) public static $requiresRuntimeIsolation = false; + // Scopes the parseView() data restore to renders that request it (the include tag). + // Views leaving data behind on the processor is arguably the real bug, but other + // renders keep that behavior for BC. Remove once that is fixed for everyone. + public static $isolateViewData = false; + public static $evaulatingTagContents = false; public static $userContentEvalState = null; @@ -251,6 +256,7 @@ public static function captureRuntimeState(): array self::$requiresRuntimeIsolation, self::$traceTagAssignments, self::$tracedRuntimeAssignments, + self::$isCascadeEnabled, ]; } @@ -265,16 +271,18 @@ public static function captureAndIsolate(): array public static function restoreState(array $capturedState): void { - [$requiresIsolation, $traceTagAssignments, $tracedRuntimeAssignments] = $capturedState; - - self::$requiresRuntimeIsolation = $requiresIsolation; - self::$traceTagAssignments = $traceTagAssignments; - self::$tracedRuntimeAssignments = $tracedRuntimeAssignments; - self::$isCascadeEnabled = true; + self::$requiresRuntimeIsolation = $capturedState[0]; + self::$traceTagAssignments = $capturedState[1]; + self::$tracedRuntimeAssignments = $capturedState[2]; + // Forcing true when absent is technically incorrect: the caller may itself be + // isolated, and this re-enables its cascade access mid-render. Preserved + // for backwards compatibility and not causing too much chaos and pain + self::$isCascadeEnabled = $capturedState[3] ?? true; } public static function resetGlobalState() { + self::$isCascadeEnabled = true; self::$templateFileStack = []; self::$shareVariablesTemplateTrigger = ''; self::$layoutVariables = []; diff --git a/src/View/Antlers/Language/Runtime/ModifierManager.php b/src/View/Antlers/Language/Runtime/ModifierManager.php index 8f7871555c7..21029e8d15c 100644 --- a/src/View/Antlers/Language/Runtime/ModifierManager.php +++ b/src/View/Antlers/Language/Runtime/ModifierManager.php @@ -12,6 +12,7 @@ use Statamic\View\Antlers\Language\Nodes\Modifiers\ModifierChainNode; use Statamic\View\Antlers\Language\Nodes\Parameters\ParameterNode; use Statamic\View\Antlers\Language\Runtime\Sandbox\Environment; +use Statamic\View\Slot; class ModifierManager { @@ -104,6 +105,10 @@ public static function evaluate($value, Environment $env, ModifierChainNode $mod return null; } + if ($value instanceof Slot) { + $value = (string) $value; + } + $returnValue = $value; foreach ($modifierChain->modifierChain as $chain) { diff --git a/src/View/Antlers/Language/Runtime/NodeProcessor.php b/src/View/Antlers/Language/Runtime/NodeProcessor.php index 1d7bf42b81a..49d20d663a6 100644 --- a/src/View/Antlers/Language/Runtime/NodeProcessor.php +++ b/src/View/Antlers/Language/Runtime/NodeProcessor.php @@ -47,6 +47,7 @@ use Statamic\View\Antlers\Language\Nodes\Structures\SwitchGroup; use Statamic\View\Antlers\Language\Nodes\VariableNode; use Statamic\View\Antlers\Language\Parser\LanguageParser; +use Statamic\View\Antlers\Language\Runtime\Concerns\ManagesIncludeSlots; use Statamic\View\Antlers\Language\Runtime\Debugging\GlobalDebugManager; use Statamic\View\Antlers\Language\Runtime\Sandbox\Environment; use Statamic\View\Antlers\Language\Runtime\Sandbox\RuntimeValues; @@ -54,11 +55,14 @@ use Statamic\View\Antlers\Language\Utilities\StringUtilities; use Statamic\View\Antlers\SyntaxError; use Statamic\View\Cascade; +use Statamic\View\Slot; use Statamic\View\State\CachesOutput; use Throwable; class NodeProcessor { + use ManagesIncludeSlots; + /** * @var Loader */ @@ -1582,6 +1586,10 @@ public function reduce($processNodes) $this->data = $lockData; } + if ($node->name->name == 'include') { + $tagParameters = $this->captureIncludeSlots($node, $tagActiveData, $tagParameters); + } + if ($node->name->name == 'partial' || $node->name->name == 'scope') { if (array_key_exists('handle_prefix', $tagParameters)) { $handlePrefixes = $tagParameters['handle_prefix']; @@ -1667,7 +1675,7 @@ public function reduce($processNodes) GlobalRuntimeState::$evaulatingTagContents = false; $this->stopMeasuringTag(); - if ($suspendedData != null) { + if ($capturedRuntimeState !== null) { $this->data = $suspendedData; GlobalRuntimeState::restoreState($capturedRuntimeState); @@ -2070,6 +2078,12 @@ public function reduce($processNodes) } if ($this->guardRuntime($node, $runtimeResult)) { + if ($runtimeResult instanceof Slot) { + $lockData = $this->data; + $runtimeResult = $runtimeResult->render(); + $this->data = $lockData; + } + $buffer .= $this->measureBufferAppend($node, $this->modifyBufferAppend($runtimeResult)); } @@ -2153,6 +2167,20 @@ public function reduce($processNodes) $val = $val->get()->all(); } + if ($val instanceof Slot) { + $lockData = $this->data; + $val = $val->render($node->hasParameters ? $this->getSlotOutputProps($node) : []); + $this->data = $lockData; + + $buffer .= $this->measureBufferAppend($node, $this->modifyBufferAppend($val)); + + if ($this->isTracingEnabled()) { + $this->runtimeConfiguration->traceManager->traceOnExit($node, null); + } + + continue; + } + $executedParamModifiers = false; if ($tagCallbackResult != null) { diff --git a/src/View/Antlers/Language/Runtime/PathDataManager.php b/src/View/Antlers/Language/Runtime/PathDataManager.php index 142535994e8..db5c5f9c048 100644 --- a/src/View/Antlers/Language/Runtime/PathDataManager.php +++ b/src/View/Antlers/Language/Runtime/PathDataManager.php @@ -33,6 +33,7 @@ use Statamic\View\Antlers\Language\Runtime\Sandbox\RuntimeValues; use Statamic\View\Antlers\Language\Utilities\StringUtilities; use Statamic\View\Cascade; +use Statamic\View\Slot; class PathDataManager { @@ -608,9 +609,7 @@ public function getData(VariableReference $path, $data, $isForArrayIndex = false } if ($didScanSourceData == false) { - if ($this->namedSlotsInScope && $pathItem->name == 'slot' && - $path->originalContent != 'slot' && - array_key_exists($path->originalContent, $data)) { + if ($this->isNamedSlotReference($pathItem, $path, $data)) { $this->reducedVar = $data[$path->originalContent]; break; } @@ -795,6 +794,14 @@ public function getData(VariableReference $path, $data, $isForArrayIndex = false return $this->reducedVar; } + private function isNamedSlotReference(PathNode $pathItem, $path, $data): bool + { + return $pathItem->name == 'slot' && + $path->originalContent != 'slot' && + array_key_exists($path->originalContent, $data) && + ($this->namedSlotsInScope || $data[$path->originalContent] instanceof Slot); + } + /** * Sets the parser instance to use when reducing content values. * @@ -1131,7 +1138,7 @@ public static function reduceForAntlers($value, Parser $parser, $data, $isPair = GlobalRuntimeState::$isEvaluatingUserData = true; GlobalRuntimeState::$isEvaluatingData = true; - if ($value instanceof Model) { + if ($value instanceof Model || $value instanceof Slot) { GlobalRuntimeState::$isEvaluatingUserData = $prevIsEvaluatingUserData; GlobalRuntimeState::$isEvaluatingData = $prevIsEvaluatingData; diff --git a/src/View/Antlers/Language/Runtime/RuntimeParser.php b/src/View/Antlers/Language/Runtime/RuntimeParser.php index e8883b58a49..f0b31e57d55 100644 --- a/src/View/Antlers/Language/Runtime/RuntimeParser.php +++ b/src/View/Antlers/Language/Runtime/RuntimeParser.php @@ -379,7 +379,7 @@ protected function renderText($text, $data = []) /** @var AntlersNode $lastTagNode */ $lastTagNode = GlobalRuntimeState::$globalTagEnterStack[count(GlobalRuntimeState::$globalTagEnterStack) - 1]; - if ($lastTagNode->name->name != 'partial') { + if (! in_array($lastTagNode->name->name, ['partial', 'include'])) { $this->documentParser->setStartLineSeed($lastTagNode->endPosition->line); } } @@ -770,9 +770,17 @@ public function parseView($view, $text, $data = []) GlobalRuntimeState::$isEvaluatingUserData = false; $existingView = $this->view; + + $shouldSwapData = GlobalRuntimeState::$isolateViewData; + GlobalRuntimeState::$isolateViewData = false; + $suspendedData = $shouldSwapData ? $this->nodeProcessor->getAllData() : null; + try { return $this->renderViewContent($view, $text, $data); } finally { + if ($shouldSwapData) { + $this->nodeProcessor->swapData($suspendedData); + } $this->view = $existingView; array_pop(GlobalRuntimeState::$templateFileStack); GlobalRuntimeState::$currentExecutionFile = $this->view; diff --git a/src/View/Blade/Concerns/CompilesPartials.php b/src/View/Blade/Concerns/CompilesPartials.php index c265501b50c..8d81bbfa33b 100644 --- a/src/View/Blade/Concerns/CompilesPartials.php +++ b/src/View/Blade/Concerns/CompilesPartials.php @@ -3,6 +3,8 @@ namespace Statamic\View\Blade\Concerns; use Illuminate\Support\Str; +use InvalidArgumentException; +use Statamic\Tags\IncludeTag; use Stillat\BladeParser\Nodes\Components\ComponentNode; use Stillat\BladeParser\Nodes\Components\ParameterNode; use Stillat\BladeParser\Nodes\Components\ParameterType; @@ -17,6 +19,26 @@ protected function isSlotTag(string $tagName): bool return $tagName === 'slot' || str($tagName)->startsWith(['slot.', 'slot:']); } + protected function compileSlotOutput(ComponentNode $component): string + { + if (! $this->isValidSlotName($name = $this->rawSlotName($component))) { + return $this->compileComponent($component); + } + + $slot = $name === 'slot' + ? '($slot ?? null)' + : '($'.IncludeTag::SLOTS_KEY.'['.var_export($name, true).'] ?? null)'; + + $context = '$'.IncludeTag::CONTEXT_KEY.' ?? false'; + $output = '\Statamic\View\Slot::output('.$slot.', '.$this->compileParameters($component->parameters).')'; + + $fallback = $this->isPairedComponent($component) + ? $this->compile($component->innerDocumentContent) + : ''; + + return ''.$fallback.''.$this->compileComponent($component).''; + } + protected function isComponentSlot(ComponentNode $parent, ComponentNode $child): bool { return $child->parent === $parent && $this->isSlotTag($child->tagName); @@ -52,47 +74,96 @@ protected function compileSlot(ComponentNode $node): array return [$name, $compiled]; } + protected function compileIncludeSlot(ComponentNode $node): array + { + $name = $this->rawSlotName($node); + + if (! $this->isValidSlotName($name)) { + throw new InvalidArgumentException("Invalid slot name [{$name}]."); + } + + return [$name, $this->compile($node->innerDocumentContent)]; + } + + protected function rawSlotName(ComponentNode $component): string + { + $name = (string) str($component->name)->substr(5); + + return $name === '' ? 'slot' : $name; + } + + protected function isValidSlotName(string $name): bool + { + return (bool) preg_match('/^[a-zA-Z_][a-zA-Z0-9_]*$/', $name); + } + protected function compilePartial(ComponentNode $component): string + { + return $this->compileViewTag($component, isInclude: false); + } + + protected function compileInclude(ComponentNode $component): string + { + return $this->compileViewTag($component, isInclude: true); + } + + protected function compileViewTag(ComponentNode $component, bool $isInclude): 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); + [$baseName, $method, $originalMethod] = $this->extractMethodNames($component); + $baseName = Str::lower($baseName); - if (! in_array($partialName, $forwardMethods)) { - $srcParam = new ParameterNode(); - $srcParam->type = ParameterType::Parameter; - $srcParam->setName('src'); - $srcParam->setValue($partialName); - $params['src'] = $srcParam; - } + if (str_contains($component->tagName, ':') && ! in_array($originalMethod, $forwardMethods)) { + $srcParam = new ParameterNode(); + $srcParam->type = ParameterType::Parameter; + $srcParam->setName('src'); + $srcParam->setValue($originalMethod); + $params['src'] = $srcParam; } $hoistedSet = ''; $hoistedUnset = ''; + $compiledSlots = array_map( + fn ($slot) => $isInclude ? $this->compileIncludeSlot($slot) : $this->compileSlot($slot), + $slots + ); + + if ($isInclude && Str::snake($method) !== 'exists') { + if (trim($newContent) !== '') { + $compiledSlots[] = ['slot', $this->compile($newContent)]; + } + + $newContent = ''; + } + // The label is randomized so slot content containing the terminator cannot break out of the nowdoc. $set = <<<'SET' -$$varName = <<<'COMPILED' +$$varName = <<<'$label' #compiled# -COMPILED; +$label; SET; $unset = <<<'UNSET' unset($$varName); UNSET; - foreach ($slots as $slot) { + foreach ($compiledSlots as [$name, $compiled]) { $hoistedVarName = '__partialSlot'.Str::random(32); - [$name, $compiled] = $this->compileSlot($slot); + $hoistedLabel = 'COMPILED'.Str::random(32); $injectedParam = new ParameterNode(); - $injectedParam->setName($name); + $paramName = $isInclude ? IncludeTag::SLOT_PARAM_PREFIX.$name : $name; + $injectedParam->setName($paramName); $injectedParam->type = ParameterType::DynamicVariable; - $injectedParam->value = 'new \Illuminate\Support\HtmlString(\Illuminate\Support\Facades\Blade::render($'.$hoistedVarName.', get_defined_vars()))'; + $injectedParam->value = $isInclude + ? '\Statamic\View\Slot::forBlade($'.$hoistedVarName.', get_defined_vars())' + : 'new \Illuminate\Support\HtmlString(\Illuminate\Support\Facades\Blade::render($'.$hoistedVarName.', get_defined_vars()))'; $hoistedSet .= Str::swap([ '$varName' => $hoistedVarName, + '$label' => $hoistedLabel, '#compiled#' => $compiled, ], $set); @@ -100,7 +171,7 @@ protected function compilePartial(ComponentNode $component): string '$varName' => $hoistedVarName, ], $unset); - $params[$name] = $injectedParam; + $params[$paramName] = $injectedParam; } $compiledNode = <<<'PHP' @@ -134,8 +205,6 @@ protected function compilePartial(ComponentNode $component): string ?> PHP; - [$name, $method, $originalMethod] = $this->extractMethodNames($component); - if (! in_array(Str::snake($method), $forwardMethods)) { $method = $originalMethod = 'index'; } @@ -149,7 +218,7 @@ protected function compilePartial(ComponentNode $component): string '#set#' => $hoistedSet, '#unset#' => $hoistedUnset, '$tagMethod' => "'".$method."'", - '$tagName' => 'partial', + '$tagName' => $baseName, '$originalMethod' => "'".$originalMethod."'", ] ); diff --git a/src/View/Blade/StatamicTagCompiler.php b/src/View/Blade/StatamicTagCompiler.php index 45d7dfc0ad9..5efd1d3576f 100644 --- a/src/View/Blade/StatamicTagCompiler.php +++ b/src/View/Blade/StatamicTagCompiler.php @@ -103,6 +103,10 @@ public function compile(string $template): string return $this->compileNocache($node); } elseif ($this->isPartial($node)) { return $this->compilePartial($node); + } elseif ($this->isInclude($node)) { + return $this->compileInclude($node); + } elseif ($this->isSlotTag($node->tagName)) { + return $this->compileSlotOutput($node); } elseif ($this->interceptNav && $this->isStructure($node->tagName)) { return $this->compileNav($node); } @@ -123,6 +127,11 @@ protected function isPartial(ComponentNode $component): bool return $component->tagName == 'partial' || str($component->tagName)->lower()->startsWith('partial:'); } + protected function isInclude(ComponentNode $component): bool + { + return $component->tagName == 'include' || str($component->tagName)->lower()->startsWith('include:'); + } + protected function extractMethodNames(ComponentNode $component): array { $name = $component->tagName; diff --git a/src/View/Slot.php b/src/View/Slot.php new file mode 100644 index 00000000000..6f31ebc6c74 --- /dev/null +++ b/src/View/Slot.php @@ -0,0 +1,213 @@ +source = $source; + $slot->callerState = $state; + + return $slot; + } + + public static function forBlade(string $template, array $data): self + { + // Prevents Blade::render from mistaking short slot content for a view name. + $template = '{{-- slot --}}'.$template; + + $slot = new self(fn (array $slotData) => Blade::render($template, $slotData), $data); + + $slot->template = $template; + + return $slot; + } + + protected static function captureCallerState(): array + { + return [GlobalRuntimeState::$isCascadeEnabled, GlobalRuntimeState::$prefixState]; + } + + protected static function antlersRenderer(array $nodes, array $callerState, NodeProcessor $processor): Closure + { + return function (array $data) use ($nodes, $callerState, $processor) { + $tagState = static::captureCallerState(); + + [GlobalRuntimeState::$isCascadeEnabled, GlobalRuntimeState::$prefixState] = $callerState; + + try { + return $processor->cloneProcessor()->setData($data)->reduce($nodes); + } finally { + [GlobalRuntimeState::$isCascadeEnabled, GlobalRuntimeState::$prefixState] = $tagState; + } + }; + } + + protected static function textRenderer(string $source, array $callerState): Closure + { + return function (array $data) use ($source, $callerState) { + $tagState = static::captureCallerState(); + + [GlobalRuntimeState::$isCascadeEnabled, GlobalRuntimeState::$prefixState] = $callerState; + + try { + return (string) app(ParserContract::class)->parse($source, $data); + } finally { + [GlobalRuntimeState::$isCascadeEnabled, GlobalRuntimeState::$prefixState] = $tagState; + } + }; + } + + public function render(array $props = []): string + { + if ($this->static && $props !== []) { + throw new RuntimeException("The {$this->displayName()} has already been rendered and cached, so data can no longer be passed to it. This usually happens when a scoped slot is rendered inside a nocache region."); + } + + $data = array_merge($this->data, ['params' => $this->params], $props); + + return trim((string) ($this->renderer)($data)); + } + + public function withParams(array $params): static + { + $this->params = $params; + + return $this; + } + + public function named(?string $name): static + { + $this->name = $name; + + return $this; + } + + public function toHtml(): string + { + return $this->render(); + } + + public function __toString(): string + { + return $this->render(); + } + + public static function output(mixed $slot, array $props = []): string + { + if ($slot instanceof self) { + return $slot->render($props); + } + + return e($slot); + } + + public function __serialize(): array + { + if ($this->source === null && $this->template === null) { + return ['name' => $this->name, 'rendered' => $this->render()]; + } + + try { + return array_filter([ + 'name' => $this->name, + 'antlers' => $this->source, + 'state' => $this->callerState, + 'template' => $this->template, + 'data' => serialize(Region::filterCacheable($this->data)), + 'params' => serialize(Region::filterCacheable($this->params)), + ], fn ($value) => $value !== null); + } catch (Throwable $e) { + throw new RuntimeException("The {$this->displayName()} cannot be cached because its scope contains values that cannot be serialized{$this->unserializableKeys()}.", previous: $e); + } + } + + public function __unserialize(array $data): void + { + $this->name = $data['name'] ?? null; + + if (array_key_exists('rendered', $data)) { + $rendered = $data['rendered']; + + $this->data = []; + $this->params = []; + $this->renderer = fn () => $rendered; + $this->static = true; + + return; + } + + $this->data = unserialize($data['data']); + $this->params = unserialize($data['params']); + + if (isset($data['template'])) { + $this->template = $data['template']; + $this->renderer = fn (array $slotData) => Blade::render($this->template, $slotData); + + return; + } + + $this->source = $data['antlers']; + $this->callerState = $data['state']; + $this->renderer = static::textRenderer($this->source, $this->callerState); + } + + protected function unserializableKeys(): string + { + $keys = collect(Region::filterCacheable(array_merge($this->data, $this->params))) + ->filter(function ($value) { + try { + serialize($value); + + return false; + } catch (Throwable) { + return true; + } + }) + ->keys(); + + return $keys->isEmpty() ? '' : ' ('.$keys->implode(', ').')'; + } + + protected function displayName(): string + { + return $this->name ? "[{$this->name}] slot" : 'slot'; + } +} diff --git a/tests/Antlers/Components/ComponentsCascadeTest.php b/tests/Antlers/Components/ComponentsCascadeTest.php index 2ce39e9fe0c..3118f9d9288 100644 --- a/tests/Antlers/Components/ComponentsCascadeTest.php +++ b/tests/Antlers/Components/ComponentsCascadeTest.php @@ -20,6 +20,22 @@ protected function createEntry() EntryFactory::collection('blog')->id('1')->slug('one')->data(['title' => 'One'])->create(); } + public function test_a_component_does_not_re_enable_the_cascade_for_an_isolated_caller() + { + $this->createEntry(); + + $this->withFakeViews(); + $this->viewShouldReturnRaw('layout', '{{ template_content }}'); + $this->viewShouldReturnRaw('default', '{{ include:shell }}'); + $this->viewShouldReturnRaw('shell', '[{{ title }}][{{ title }}]'); + $this->viewShouldReturnRaw('components.scope.cascade', 'C'); + + $this->assertSame( + '[]C[]', + Str::squish($this->get('one')->assertOk()->getContent()) + ); + } + public function test_cascade_does_not_leak_into_components() { $this->createEntry(); diff --git a/tests/Antlers/Runtime/Includes/CascadeTest.php b/tests/Antlers/Runtime/Includes/CascadeTest.php new file mode 100644 index 00000000000..de4908632bd --- /dev/null +++ b/tests/Antlers/Runtime/Includes/CascadeTest.php @@ -0,0 +1,280 @@ +withFakeViews(); + + Cascade::set('cval', 'C'); + } + + private function render($template, $data = []) + { + return $this->renderString($template, $data, true); + } + + public function test_a_view_only_reaches_the_cascade_when_it_asks_to() + { + $this->viewShouldReturnRaw('x', 'X[{{ cval }}]'); + + $this->assertSame('X[]', $this->render('{{ include:x }}')); + $this->assertSame('X[C]', $this->render('{{ include:x cascade="true" }}')); + } + + public function test_the_caller_keeps_the_cascade_on_both_sides_of_an_include() + { + $this->viewShouldReturnRaw('x', 'X'); + + $this->assertSame('[C]X[C]', $this->render('[{{ cval }}]{{ include:x }}[{{ cval }}]')); + } + + public function test_the_caller_keeps_the_cascade_after_an_include_in_a_loop() + { + $this->viewShouldReturnRaw('r', 'R'); + + $this->assertSame('RR[C]', $this->render('{{ items }}{{ include:r }}{{ /items }}[{{ cval }}]', ['items' => [[], []]])); + } + + public function test_a_nested_include_does_not_inherit_the_cascade() + { + $this->viewShouldReturnRaw('l1', 'L1[{{ cval }}]{{ include:l2 }}'); + $this->viewShouldReturnRaw('l2', 'L2[{{ cval }}]'); + + $this->assertSame('L1[C]L2[]', $this->render('{{ include:l1 cascade="true" }}')); + } + + public function test_a_nested_include_does_not_enable_the_cascade_for_its_parent() + { + $this->viewShouldReturnRaw('outer', '[{{ cval }}]{{ include:inner }}[{{ cval }}]'); + $this->viewShouldReturnRaw('inner', '[{{ cval }}]'); + + $this->assertSame('[][][]', $this->render('{{ include:outer }}')); + } + + public function test_a_nested_include_can_still_opt_in() + { + $this->viewShouldReturnRaw('l1', 'L1[{{ cval }}]{{ include:l2 cascade="true" }}'); + $this->viewShouldReturnRaw('l2', 'L2[{{ cval }}]'); + + $this->assertSame('L1[]L2[C]', $this->render('{{ include:l1 }}')); + } + + public function test_slot_contents_resolve_cascade_values_like_the_caller_does() + { + $this->viewShouldReturnRaw('default', '{{ slot }}'); + $this->viewShouldReturnRaw('named', '{{ slot:h }}'); + $this->viewShouldReturnRaw('scoped', '{{ slot:h :n="1" }}'); + + $this->assertSame('[C]', $this->render('{{ include:default }}[{{ cval }}]{{ /include:default }}')); + $this->assertSame('[C]', $this->render('{{ include:named }}{{ slot:h }}[{{ cval }}]{{ /slot:h }}{{ /include:named }}')); + $this->assertSame('[C|1]', $this->render('{{ include:scoped }}{{ slot:h }}[{{ cval }}|{{ n }}]{{ /slot:h }}{{ /include:scoped }}')); + } + + public function test_slot_contents_written_inside_a_view_use_that_views_cascade_state() + { + $this->viewShouldReturnRaw('outer', '{{ include:wrapper }}[{{ cval }}]{{ /include:wrapper }}'); + $this->viewShouldReturnRaw('wrapper', '{{ slot }}'); + + $this->assertSame('[]', $this->render('{{ include:outer }}')); + $this->assertSame('[C]', $this->render('{{ include:outer cascade="true" }}')); + } + + public function test_a_partial_rendered_inside_an_include_follows_the_includes_cascade_state() + { + $this->viewShouldReturnRaw('x', 'X[{{ cval }}]{{ partial:p }}'); + $this->viewShouldReturnRaw('p', 'P[{{ cval }}]'); + + $this->assertSame('X[]P[]|[C]', $this->render('{{ include:x }}|[{{ cval }}]')); + $this->assertSame('X[C]P[C]|[C]', $this->render('{{ include:x cascade="true" }}|[{{ cval }}]')); + } + + public function test_an_include_inside_a_partial_leaves_the_partials_cascade_alone() + { + $this->viewShouldReturnRaw('p', 'P[{{ cval }}]{{ include:x }}P[{{ cval }}]'); + $this->viewShouldReturnRaw('x', 'X'); + + $this->assertSame('P[C]XP[C][C]', $this->render('{{ partial:p }}[{{ cval }}]')); + } + + public function test_a_blade_include_leaves_the_surrounding_antlers_cascade_alone() + { + $this->viewShouldReturnRaw('b', 'B[{{ $cval ?? "" }}]', 'blade.php'); + + $this->assertSame('[C]B[][C]', $this->render('[{{ cval }}]{{ include:b }}[{{ cval }}]')); + $this->assertSame('[C]B[C][C]', $this->render('[{{ cval }}]{{ include:b cascade="true" }}[{{ cval }}]')); + } + + public function test_blade_includes_reach_the_cascade_when_they_ask_to() + { + $this->viewShouldReturnRaw('b', 'B[{{ $cval ?? "" }}]', 'blade.php'); + + $this->assertSame('B[]', Blade::render('')); + $this->assertSame('B[C]', Blade::render('')); + } + + public function test_a_blade_invoked_antlers_view_cannot_see_the_cascade() + { + $this->viewShouldReturnRaw('a', '[{{ cval }}]'); + + $this->assertSame('[]', Blade::render('')); + $this->assertSame('[C]', Blade::render('')); + } + + public function test_an_include_inside_a_blade_view_cannot_see_the_cascade() + { + $this->viewShouldReturnRaw('shell', '|', 'blade.php'); + $this->viewShouldReturnRaw('a', '[{{ cval }}]'); + + $this->assertSame('[]|[C]', view('shell')->render()); + } + + public function test_runtime_state_survives_an_exception_thrown_inside_an_include() + { + (new class extends Tags + { + protected static $handle = 'explode'; + + public function index() + { + throw new RuntimeException('boom'); + } + })::register(); + + $this->viewShouldReturnRaw('boom', '{{ explode }}'); + $this->viewShouldReturnRaw('ok', 'OK'); + + try { + $this->render('{{ include:boom }}'); + $this->fail('The exception should not have been swallowed.'); + } catch (RuntimeException $e) { + $this->assertSame('boom', $e->getMessage()); + } + + $this->assertTrue(GlobalRuntimeState::$isCascadeEnabled); + $this->assertFalse(GlobalRuntimeState::$requiresRuntimeIsolation); + $this->assertNull(Cascade::get('views')); + $this->assertSame('OK[C]', $this->render('{{ include:ok }}[{{ cval }}]')); + } + + public function test_runtime_state_survives_an_exception_thrown_inside_a_deferred_slot_render() + { + (new class extends Tags + { + protected static $handle = 'slot_boom'; + + public function index() + { + throw new RuntimeException('boom'); + } + })::register(); + + $this->viewShouldReturnRaw('w', '{{ slot }}'); + $this->viewShouldReturnRaw('ok', 'OK'); + + try { + $this->render('{{ include:w }}{{ slot_boom }}{{ /include:w }}'); + $this->fail('The exception should not have been swallowed.'); + } catch (RuntimeException $e) { + $this->assertSame('boom', $e->getMessage()); + } + + $this->assertTrue(GlobalRuntimeState::$isCascadeEnabled); + $this->assertSame([], GlobalRuntimeState::$prefixState); + $this->assertFalse(GlobalRuntimeState::$requiresRuntimeIsolation); + $this->assertSame('OK[C]', $this->render('{{ include:ok }}[{{ cval }}]')); + } + + public function test_any_isolated_tag_restores_the_previous_cascade_state() + { + $tag = new class extends Tags + { + protected static $handle = 'some_isolated_tag'; + + public static $isolated = true; + + public function index() + { + return ''; + } + }; + $tag::register(); + + $probe = new class extends Tags + { + protected static $handle = 'cascade_probe'; + + public static $seen = null; + + public function index() + { + self::$seen = GlobalRuntimeState::$isCascadeEnabled; + + return ''; + } + }; + $probe::register(); + + $this->viewShouldReturnRaw('x', '{{ some_isolated_tag }}{{ cascade_probe }}'); + + $this->render('{{ include:x }}'); + + $this->assertFalse( + $probe::$seen, + 'An isolated tag must not hand cascade access back to a caller that had isolated itself.' + ); + } + + public function test_an_include_restores_the_previous_handle_prefixes() + { + $tag = new class extends Tags + { + protected static $handle = 'prefix_probe'; + + public static $seen = null; + + public function index() + { + self::$seen = GlobalRuntimeState::$prefixState; + + return ''; + } + }; + $tag::register(); + + $this->viewShouldReturnRaw('probe', '{{ prefix_probe }}'); + + GlobalRuntimeState::$prefixState = ['hero_']; + + try { + $this->render('{{ include:probe }}'); + + $this->assertSame([], $tag::$seen, 'An include should not inherit the caller\'s handle prefixes.'); + $this->assertSame(['hero_'], GlobalRuntimeState::$prefixState); + } finally { + GlobalRuntimeState::$prefixState = []; + } + } + + public function test_resetting_global_state_restores_cascade_access() + { + GlobalRuntimeState::$isCascadeEnabled = false; + + GlobalRuntimeState::resetGlobalState(); + + $this->assertTrue(GlobalRuntimeState::$isCascadeEnabled); + } +} diff --git a/tests/Antlers/Runtime/Includes/IncludeTagTest.php b/tests/Antlers/Runtime/Includes/IncludeTagTest.php new file mode 100644 index 00000000000..5b6f1f4d41c --- /dev/null +++ b/tests/Antlers/Runtime/Includes/IncludeTagTest.php @@ -0,0 +1,249 @@ +withFakeViews(); + } + + private function render($template, $data = []) + { + return $this->renderString($template, $data, true); + } + + public function test_it_renders_a_view() + { + $this->viewShouldReturnRaw('greeting', 'Hello'); + + $this->assertSame('Hello', $this->render('{{ include:greeting }}')); + } + + public function test_it_renders_a_view_using_the_src_form() + { + $this->viewShouldReturnRaw('greeting', 'Hi'); + + $this->assertSame('Hi', $this->render('{{ include src="greeting" }}')); + } + + public function test_an_empty_src_is_rejected() + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('The include tag requires a view name or the [src] parameter.'); + + $this->render('{{ include src="" }}'); + } + + public function test_a_view_named_index_can_still_be_included() + { + $this->viewShouldReturnRaw('index', 'IDX'); + + $this->assertSame('IDX', $this->render('{{ include:index }}')); + } + + public function test_params_are_available_as_variables() + { + $this->viewShouldReturnRaw('greeting', 'Hello {{ name }}'); + + $this->assertSame('Hello World', $this->render('{{ include:greeting name="World" }}')); + } + + public function test_caller_scope_is_not_captured() + { + $this->viewShouldReturnRaw('greeting', '[{{ secret }}]'); + + $this->assertSame('[]', $this->render('{{ include:greeting }}', ['secret' => 'leak'])); + } + + public function test_loop_variables_are_not_captured() + { + $this->viewShouldReturnRaw('item', '[{{ value }}]'); + + $template = '{{ items }}{{ include:item }}{{ /items }}'; + + $this->assertSame('[][]', $this->render($template, ['items' => [['value' => 'a'], ['value' => 'b']]])); + } + + public function test_assignments_inside_an_include_do_not_leak_out() + { + $this->viewShouldReturnRaw('assigner', '{{ leaked = "in-include" }}{{ leaked }}'); + + $template = '{{ include:assigner }}|{{ leaked }}'; + + $this->assertSame('in-include|', $this->render($template)); + } + + public function test_reassigning_a_passed_variable_does_not_change_the_caller() + { + $this->viewShouldReturnRaw('reassign', '{{ foo = "changed" }}{{ foo }}'); + + $template = '{{ foo = "original" }}{{ foo }}|{{ include:reassign :foo="foo" }}|{{ foo }}'; + + $this->assertSame('original|changed|original', $this->render($template)); + } + + public function test_params_array_is_spread_into_the_scope_and_overridden_by_explicit_params() + { + $this->viewShouldReturnRaw('card', '<{{ title }}><{{ subtitle }}>'); + + $data = ['title' => 'T', 'subtitle' => 'S']; + + $this->assertSame('', $this->render('{{ include:card :params="data" }}', ['data' => $data])); + $this->assertSame('', $this->render('{{ include:card :params="data" title="Override" }}', ['data' => $data])); + } + + public function test_params_accessor_returns_the_merged_params() + { + $this->viewShouldReturnRaw('card', '[{{ params:title }}][{{ params:subtitle }}]'); + + $this->assertSame( + '[Named][S]', + $this->render('{{ include:card :params="data" title="Named" }}', ['data' => ['title' => 'T', 'subtitle' => 'S']]) + ); + } + + public function test_control_params_set_on_the_tag_never_appear_as_data() + { + $this->viewShouldReturnRaw('card', '[{{ handle_prefix }}][{{ when }}][{{ params:handle_prefix }}][{{ params:when }}][{{ params:params }}]'); + + $this->assertSame( + '[][][][][]', + $this->render('{{ include:card :params="data" handle_prefix="x_" when="true" }}', ['data' => ['title' => 'T']]) + ); + } + + public function test_spread_keys_named_after_control_params_are_preserved_as_data() + { + $this->viewShouldReturnRaw('card', '[{{ src }}][{{ params:src }}][{{ when }}][{{ params:when }}]'); + + $this->assertSame( + '[/img.jpg][/img.jpg][W][W]', + $this->render('{{ include:card :params="data" }}', ['data' => ['src' => '/img.jpg', 'when' => 'W']]) + ); + } + + public function test_an_entry_with_a_src_field_can_be_spread_into_an_include() + { + $this->viewShouldReturnRaw('card', ''); + + $entry = ['title' => 'My Video', 'src' => 'https://example.com/video.mp4']; + + $this->assertSame( + '', + $this->render('{{ include:card :params="entry" }}', ['entry' => $entry]) + ); + } + + public function test_params_must_be_an_associative_array() + { + $this->viewShouldReturnRaw('card', 'C'); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('must be an associative array'); + + $this->render('{{ include:card :params="bad" }}', ['bad' => ['a', 'b', 'c']]); + } + + public function test_reserved_params_cannot_be_spread() + { + $this->viewShouldReturnRaw('card', 'Card'); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Cannot pass reserved parameter [__frontmatter]'); + + $this->render('{{ include:card :params="data" }}', [ + 'data' => ['__frontmatter' => 'value'], + ]); + } + + public function test_handle_prefix_exposes_spread_values_under_both_names() + { + $this->viewShouldReturnRaw('hero', '<{{ title }}><{{ body }}>[{{ hero_title }}][{{ params:hero_title }}][{{ other }}]'); + + $template = '{{ include:hero :params="data" handle_prefix="hero_" }}'; + + $this->assertSame( + '[HT][HT][O]', + $this->render($template, ['data' => ['hero_title' => 'HT', 'hero_body' => 'HB', 'other' => 'O']]) + ); + } + + public function test_a_prefixed_spread_value_wins_over_a_non_prefixed_one() + { + $this->viewShouldReturnRaw('hero', '<{{ title }}>'); + + $template = '{{ include:hero :params="data" handle_prefix="hero_" }}'; + + $this->assertSame( + '', + $this->render($template, ['data' => ['hero_title' => 'PREFIXED', 'title' => 'PLAIN']]) + ); + } + + public function test_handle_prefix_also_applies_to_parameters_set_on_the_tag() + { + $this->viewShouldReturnRaw('hero', '<{{ title }}><{{ hero_title }}>'); + + $this->assertSame('', $this->render('{{ include:hero handle_prefix="hero_" hero_title="HT" }}')); + } + + public function test_a_parameter_set_on_the_tag_wins_under_both_names() + { + $this->viewShouldReturnRaw('hero', '<{{ title }}><{{ hero_title }}>'); + + $data = ['d' => ['hero_title' => 'FROM-SPREAD']]; + + $this->assertSame('', $this->render('{{ include:hero :params="d" handle_prefix="hero_" hero_title="OVERRIDE" }}', $data)); + $this->assertSame('', $this->render('{{ include:hero :params="d" handle_prefix="hero_" title="OVERRIDE" }}', $data)); + } + + public function test_handle_prefix_leaves_keys_it_would_reduce_to_nothing_alone() + { + $this->viewShouldReturnRaw('hero', '<{{ title }}>[{{ hero_ }}]'); + + $this->assertSame( + '[X]', + $this->render('{{ include:hero :params="d" handle_prefix="hero_" }}', ['d' => ['hero_' => 'X', 'hero_title' => 'T']]) + ); + } + + public function test_when_param_controls_rendering() + { + $this->viewShouldReturnRaw('greeting', 'Hello'); + + $this->assertSame('', $this->render('{{ include:greeting when="false" }}')); + $this->assertSame('Hello', $this->render('{{ include:greeting when="true" }}')); + } + + public function test_unless_param_controls_rendering() + { + $this->viewShouldReturnRaw('greeting', 'Hello'); + + $this->assertSame('', $this->render('{{ include:greeting unless="true" }}')); + $this->assertSame('Hello', $this->render('{{ include:greeting unless="false" }}')); + } + + public function test_include_if_exists_renders_the_view_when_it_exists() + { + $this->viewShouldReturnRaw('cards.author', 'Author'); + + $this->assertSame('Author', $this->render('{{ include:if_exists src="cards/author" }}')); + $this->assertSame('', $this->render('{{ include:if_exists src="nope" }}')); + } + + public function test_include_exists_in_a_condition() + { + $this->viewShouldReturnRaw('cards.author', 'Author'); + + $this->assertSame('yes', $this->render('{{ if {include:exists src="cards/author"} }}yes{{ else }}no{{ /if }}')); + $this->assertSame('no', $this->render('{{ if {include:exists src="nope"} }}yes{{ else }}no{{ /if }}')); + } +} diff --git a/tests/Antlers/Runtime/Includes/InteropTest.php b/tests/Antlers/Runtime/Includes/InteropTest.php new file mode 100644 index 00000000000..31ca9b91bd9 --- /dev/null +++ b/tests/Antlers/Runtime/Includes/InteropTest.php @@ -0,0 +1,195 @@ +withFakeViews(); + } + + private function render($template, $data = []) + { + return $this->renderString($template, $data, true); + } + + private function tree(): array + { + return [ + ['title' => 'A', 'children' => [ + ['title' => 'A1', 'children' => []], + ['title' => 'A2', 'children' => [['title' => 'A2a', 'children' => []]]], + ]], + ['title' => 'B', 'children' => []], + ]; + } + + public function test_a_slot_can_be_forwarded_into_a_nested_include() + { + $this->viewShouldReturnRaw('outer', 'O{{ include:inner }}{{ slot }}{{ /include:inner }}'); + $this->viewShouldReturnRaw('as_param', 'O{{ include:inner :slot="slot" }}'); + $this->viewShouldReturnRaw('inner', 'I<{{ slot }}>'); + + $this->assertSame('OI', $this->render('{{ include:outer }}BODY{{ /include:outer }}')); + $this->assertSame('OI', $this->render('{{ include:as_param }}BODY{{ /include:as_param }}')); + } + + public function test_a_slot_can_be_forwarded_through_several_levels() + { + $this->viewShouldReturnRaw('l1', '1{{ include:l2 }}<{{ slot }}>{{ /include:l2 }}'); + $this->viewShouldReturnRaw('l2', '2{{ include:l3 }}[{{ slot }}]{{ /include:l3 }}'); + $this->viewShouldReturnRaw('l3', '3({{ slot }})'); + + $this->assertSame('123([])', $this->render('{{ include:l1 }}TOP{{ /include:l1 }}')); + } + + public function test_a_slot_may_be_passed_to_another_include_under_a_different_name() + { + $this->viewShouldReturnRaw('outer', 'O{{ include:target :x="slot" }}'); + $this->viewShouldReturnRaw('target', 'T<{{ x }}>[{{ params:x }}]'); + + $this->assertSame('OT[BODY]', $this->render('{{ include:outer }}BODY{{ /include:outer }}')); + } + + public function test_stacks_can_be_pushed_to_from_a_view_and_from_slot_contents() + { + $this->viewShouldReturnRaw('pusher', '{{ push:s }}A{{ /push:s }}X'); + $this->viewShouldReturnRaw('prepender', '{{ prepend:s }}B{{ /prepend:s }}Y'); + $this->viewShouldReturnRaw('wrapper', 'W{{ slot }}'); + + $this->assertSame('BA|XY', $this->render('{{ stack:s }}|{{ include:pusher }}{{ include:prepender }}')); + $this->assertSame( + 'P|WSLOT', + $this->render('{{ stack:s }}|{{ include:wrapper }}{{ push:s }}P{{ /push:s }}SLOT{{ /include:wrapper }}') + ); + } + + public function test_sections_can_be_defined_in_a_view_and_in_slot_contents() + { + $this->viewShouldReturnRaw('definer', '{{ section:s }}FROM-VIEW{{ /section:s }}X'); + $this->viewShouldReturnRaw('wrapper', 'W{{ slot }}'); + + $this->assertSame('FROM-VIEW|X', $this->render('{{ yield:s }}|{{ include:definer }}')); + $this->assertSame( + 'FROM-SLOT|WX', + $this->render('{{ yield:s }}|{{ include:wrapper }}{{ section:s }}FROM-SLOT{{ /section:s }}X{{ /include:wrapper }}') + ); + } + + public function test_a_view_can_yield_a_section_the_caller_defined() + { + $this->viewShouldReturnRaw('w', 'W[{{ yield:s }}]'); + + $this->assertSame('W[OUTER]', $this->render('{{ section:s }}OUTER{{ /section:s }}{{ include:w }}')); + } + + public function test_once_only_renders_once_across_repeated_includes() + { + $this->viewShouldReturnRaw('p', '{{ once }}ONCE{{ /once }}X'); + $this->viewShouldReturnRaw('w', '{{ slot }}{{ slot }}'); + + $this->assertSame('ONCEXX', $this->render('{{ include:p }}{{ include:p }}')); + $this->assertSame('ONCEXX', $this->render('{{ items }}{{ include:p }}{{ /items }}', ['items' => [[], []]])); + $this->assertSame('OXX', $this->render('{{ include:w }}{{ once }}O{{ /once }}X{{ /include:w }}')); + } + + public function test_noparse_and_escaped_literals_survive_slot_contents() + { + $this->viewShouldReturnRaw('p', '{{ noparse }}{{ title }}{{ /noparse }}|{{ title }}'); + $this->viewShouldReturnRaw('w', 'W{{ slot }}'); + + $this->assertSame('{{ title }}|T', $this->render('{{ include:p title="T" }}')); + $this->assertSame('W{{ x }}', $this->render('{{ include:w }}{{ noparse }}{{ x }}{{ /noparse }}{{ /include:w }}', ['x' => 'X'])); + $this->assertSame('W{{ x }}', $this->render('{{ include:w }}@{{ x }}{{ /include:w }}', ['x' => 'X'])); + } + + public function test_recursive_nodes_work_around_inside_and_within_slots_of_an_include() + { + $this->viewShouldReturnRaw('item', '{{ t }}'); + $this->viewShouldReturnRaw('menu', '{{ nav }}[{{ title }}]{{ if children }}
    {{ *recursive children* }}
{{ /if }}{{ /nav }}'); + $this->viewShouldReturnRaw('wrapper', 'W{{ slot }}'); + + $recursive = '{{ nav }}[{{ title }}]{{ if children }}
    {{ *recursive children* }}
{{ /if }}{{ /nav }}'; + + $this->assertSame( + 'A
    A1A2
      A2a
B', + $this->render('{{ nav }}{{ include:item :t="title" }}{{ if children }}
    {{ *recursive children* }}
{{ /if }}{{ /nav }}', ['nav' => $this->tree()]) + ); + + $this->assertSame( + '[A]
    [A1][A2]
      [A2a]
[B]', + $this->render('{{ include:menu :nav="tree" }}', ['tree' => $this->tree()]) + ); + + $this->assertSame( + 'W[A]
    [A1][A2]
      [A2a]
[B]
', + $this->render('{{ include:wrapper }}'.$recursive.'{{ /include:wrapper }}', ['nav' => $this->tree()]) + ); + } + + public function test_query_builders_can_be_passed_to_an_include_without_leaking() + { + $builder = Mockery::mock(Builder::class); + $builder->shouldReceive('get')->andReturn(collect([['title' => 'Foo'], ['title' => 'Bar']])); + $builder->shouldReceive('orderBy')->andReturnSelf(); + + $this->viewShouldReturnRaw('list', '{{ rows order_by="title:desc" }}<{{ title }}>{{ /rows }}'); + $this->viewShouldReturnRaw('empty', 'E[{{ rows }}]'); + + $this->assertSame( + 'E[]', + $this->render('{{ include:list :rows="data" }}{{ include:empty }}', ['data' => $builder]) + ); + } + + public function test_augmented_values_survive_being_passed_as_parameters() + { + $this->viewShouldReturnRaw('p', '[{{ v }}][{{ v | upper }}][{{ params:v }}]'); + + $this->assertSame('[hello][HELLO][hello]', $this->render('{{ include:p :v="v" }}', ['v' => new Value('hello')])); + } + + public function test_handle_prefix_accepts_a_list_of_prefixes() + { + $this->viewShouldReturnRaw('hero', '[{{ title }}][{{ body }}]'); + + $this->assertSame('[AT][BB]', $this->render('{{ include:hero :params="d" :handle_prefix="pf" }}', [ + 'd' => ['a_title' => 'AT', 'b_body' => 'BB'], + 'pf' => ['a_', 'b_'], + ])); + + $this->viewShouldReturnRaw('both', '[{{ title }}][{{ a_title }}][{{ b_title }}]'); + + $this->assertSame('[FIRST][FIRST][SECOND]', $this->render('{{ include:both :params="d" :handle_prefix="pf" }}', [ + 'd' => ['a_title' => 'FIRST', 'b_title' => 'SECOND'], + 'pf' => ['a_', 'b_'], + ])); + } + + public function test_the_cache_tag_works_around_and_inside_an_include_with_slots() + { + $this->viewShouldReturnRaw('w', 'W{{ slot }}'); + $this->viewShouldReturnRaw('cw', '{{ cache }}W{{ slot }}{{ /cache }}'); + + $this->assertSame('WBODY', $this->render('{{ cache }}{{ include:w }}BODY{{ /include:w }}{{ /cache }}')); + $this->assertSame('WBODY', $this->render('{{ include:cw }}BODY{{ /include:cw }}')); + } + + public function test_slot_contents_do_not_see_the_views_front_matter() + { + $this->viewShouldReturnRaw('fm', "---\nk: FM\n---\n[{{ view:k }}]<{{ slot }}>"); + + $this->assertSame('[FM]', trim($this->render('{{ include:fm }}BODY{{ /include:fm }}'))); + $this->assertSame('[FM]<[]>', trim($this->render('{{ include:fm }}[{{ view:k }}]{{ /include:fm }}'))); + } +} diff --git a/tests/Antlers/Runtime/Includes/IssuesTest.php b/tests/Antlers/Runtime/Includes/IssuesTest.php new file mode 100644 index 00000000000..212d64e7533 --- /dev/null +++ b/tests/Antlers/Runtime/Includes/IssuesTest.php @@ -0,0 +1,70 @@ +withFakeViews(); + } + + private function render($template, $data = []) + { + return $this->renderString($template, $data, true); + } + + public function test_issue_8175_assigned_variables_never_leak_consistently() + { + $this->viewShouldReturnRaw('noop', ''); + $this->viewShouldReturnRaw('setter', '{{ $var = "SET" }}'); + $this->viewShouldReturnRaw('setter_extra', '{{ $var = "SET" }}{{ partial:noop }}'); + + $this->assertSame('|[]', $this->render('{{ include:setter }}|[{{ $var }}]')); + $this->assertSame('|[]', $this->render('{{ include:setter_extra }}|[{{ $var }}]')); + } + + public function test_issue_10703_params_do_not_leak_into_the_next_include() + { + $this->viewShouldReturnRaw('cardA', '[{{ class }}|{{ view:class }}]'); + $this->viewShouldReturnRaw('cardB', '[{{ class }}|{{ view:class }}]'); + + $this->assertSame( + '[cool|cool][|]', + $this->render('{{ include:cardA class="cool" }}{{ include:cardB }}') + ); + } + + public function test_issue_11486_frontmatter_does_not_leak_across_inclusions() + { + $this->viewShouldReturnRaw('inc_a', "---\nvar_a: A\n---\nA[{{ view:var_a }}]"); + $this->viewShouldReturnRaw('inc_b', "---\nvar_b: B\n---\nB[{{ view:var_b }}]{{ include:inc_a }}"); + + $template = '{{ include:inc_b }}{{ include:inc_b }}|HOME[{{ view:var_a }}|{{ view:var_b }}]'; + + $this->assertSame('B[B]A[A]B[B]A[A]|HOME[|]', $this->render($template)); + $this->assertNull(Cascade::get('views')); + } + + public function test_issue_12709_isolation_is_consistent_across_conditional_forms() + { + $this->viewShouldReturnRaw('mod', '{{ foo = "changed" }}M'); + + $this->assertSame( + 'M|orig', + $this->render('{{ foo = "orig" }}{{ if bar }}{{ include:mod }}{{ /if }}|{{ foo }}', ['bar' => true]) + ); + + $this->assertSame( + 'M|orig', + $this->render('{{ foo = "orig" }}{{ bar ?= { include:mod } }}|{{ foo }}', ['bar' => true]) + ); + } +} diff --git a/tests/Antlers/Runtime/Includes/NestedTest.php b/tests/Antlers/Runtime/Includes/NestedTest.php new file mode 100644 index 00000000000..dc3600ff117 --- /dev/null +++ b/tests/Antlers/Runtime/Includes/NestedTest.php @@ -0,0 +1,146 @@ +withFakeViews(); + } + + private function render($template, $data = []) + { + return $this->renderString($template, $data, true); + } + + public function test_three_levels_deep() + { + $this->viewShouldReturnRaw('level1', 'L1[{{ include:level2 }}]'); + $this->viewShouldReturnRaw('level2', 'L2[{{ include:level3 }}]'); + $this->viewShouldReturnRaw('level3', 'L3'); + + $this->assertSame('L1[L2[L3]]', $this->render('{{ include:level1 }}')); + } + + public function test_caller_scope_does_not_reach_any_level() + { + $this->viewShouldReturnRaw('level1', 'L1[{{ a }}]{{ include:level2 }}'); + $this->viewShouldReturnRaw('level2', 'L2[{{ a }}]'); + + $this->assertSame('L1[]L2[]', $this->render('{{ include:level1 }}', ['a' => 'caller'])); + } + + public function test_params_do_not_implicitly_flow_to_deeper_includes() + { + $this->viewShouldReturnRaw('level1', 'L1[{{ b }}]{{ include:level2 }}'); + $this->viewShouldReturnRaw('level2', 'L2[{{ b }}]'); + + $this->assertSame('L1[x]L2[]', $this->render('{{ include:level1 b="x" }}')); + } + + public function test_data_can_be_threaded_down_explicitly() + { + $this->viewShouldReturnRaw('level1', 'L1[{{ a }}]{{ include:level2 :a="a" }}'); + $this->viewShouldReturnRaw('level2', 'L2[{{ a }}]'); + + $this->assertSame('L1[passed]L2[passed]', $this->render('{{ include:level1 a="passed" }}')); + } + + public function test_same_variable_name_at_each_level_stays_isolated() + { + $this->viewShouldReturnRaw('level1', '{{ x = "1" }}{{ x }}{{ include:level2 }}{{ x }}'); + $this->viewShouldReturnRaw('level2', '{{ x = "2" }}{{ x }}'); + + $template = '{{ x = "0" }}{{ include:level1 }}{{ x }}'; + + $this->assertSame('1210', $this->render($template)); + } + + public function test_params_accessor_reflects_each_levels_own_params() + { + $this->viewShouldReturnRaw('level1', 'L1{{ params:p }}{{ include:level2 p="two" }}'); + $this->viewShouldReturnRaw('level2', 'L2{{ params:p }}'); + + $this->assertSame('L1oneL2two', $this->render('{{ include:level1 p="one" }}')); + } + + public function test_outer_slots_do_not_leak_into_a_nested_include() + { + $this->viewShouldReturnRaw('outer', '{{ slot:otitle }}{{ include:inner }}{{ slot:ititle }}INNER{{ /slot:ititle }}{{ /include:inner }}'); + $this->viewShouldReturnRaw('inner', '{{ slot:ititle }}[{{ slot:otitle }}]'); + + $template = '{{ include:outer }}{{ slot:otitle }}OUTER{{ /slot:otitle }}{{ /include:outer }}'; + + $this->assertSame('OUTERINNER[]', $this->render($template)); + } + + public function test_a_slot_with_an_include_still_sees_the_callers_scope() + { + $this->viewShouldReturnRaw('wrapper', '{{ slot }}'); + $this->viewShouldReturnRaw('inner', 'I[{{ name }}]'); + + $template = '{{ include:wrapper }}{{ include:inner :name="caller_var" }}{{ /include:wrapper }}'; + + $this->assertSame('I[CV]', $this->render($template, ['caller_var' => 'CV'])); + } + + public function test_looped_includes_keep_params_and_slots_isolated() + { + $this->viewShouldReturnRaw('row', '{{ n }}:{{ slot }}:{{ params:n }};'); + $items = collect()->range(1, 10)->map(fn ($value) => compact('value'))->all(); + $expected = collect()->range(1, 10)->map(fn ($value) => "{$value}:{$value}:{$value};")->implode(''); + + $template = '{{ items }}{{ include:row :n="value" }}{{ value }}{{ /include:row }}{{ /items }}'; + + $this->assertSame($expected, $this->render($template, ['items' => $items])); + } + + public function test_scope_is_preserved_through_alternating_view_engines() + { + Cascade::set('secret', 'cascade'); + $this->viewShouldReturnRaw('outer', 'O[{{ label }}|{{ secret }}]{{ include:middle :label="label" :rows="rows" }}'); + $this->viewShouldReturnRaw('middle', 'M[{{ $label }}|{{ $secret ?? \'\' }}][{{ $params[\'label\'] }}:{{ $value }}:{{ $secret ?? \'\' }}]', 'blade.php'); + $this->viewShouldReturnRaw('inner', 'I[{{ label }}|{{ secret }}]{{ rows }}{{ slot:item :value="value" }}{{ /rows }}'); + + $template = '{{ include:outer label="L" :rows="rows" }}'; + + $this->assertSame('O[L|]M[L|]I[L|][L:A:][L:B:]', $this->render($template, [ + 'secret' => 'caller', + 'rows' => [['value' => 'A'], ['value' => 'B']], + ])); + } + + public function test_recursive_include_with_termination() + { + $this->viewShouldReturnRaw('tree', '{{ if depth > 0 }}{{ include:tree :depth="depth|subtract:1" }}{{ /if }}'); + + $this->assertSame('', $this->render('{{ include:tree :depth="3" }}')); + } + + public function test_an_include_with_its_own_slot_can_live_inside_a_named_slot() + { + $this->viewShouldReturnRaw('card', '{{ slot:header }}'); + $this->viewShouldReturnRaw('badge', '{{ slot }}'); + + $template = '{{ include:card }}{{ slot:header }}{{ include:badge }}LBL{{ /include:badge }}{{ /slot:header }}{{ /include:card }}'; + + $this->assertSame('LBL', $this->render($template)); + } + + public function test_an_assignment_in_slot_content_does_not_leak() + { + $this->viewShouldReturnRaw('wrapper', '{{ slot }}[{{ leaked }}]'); + + $template = '{{ include:wrapper }}{{ leaked = "fromslot" }}{{ leaked }}{{ /include:wrapper }}|{{ leaked }}'; + + $this->assertSame('fromslot[]|', $this->render($template)); + } +} diff --git a/tests/Antlers/Runtime/Includes/SandboxTest.php b/tests/Antlers/Runtime/Includes/SandboxTest.php new file mode 100644 index 00000000000..722c5950211 --- /dev/null +++ b/tests/Antlers/Runtime/Includes/SandboxTest.php @@ -0,0 +1,159 @@ +withFakeViews(); + } + + private function render($template, $data = []) + { + return $this->renderString($template, $data, true); + } + + public function test_an_enclosing_partials_handle_prefix_does_not_reach_the_include() + { + $this->viewShouldReturnRaw('shell', '{{ include:leaf :params="d" }}'); + $this->viewShouldReturnRaw('leaf', 'leaf[{{ title }}]'); + + $data = ['d' => ['hero_title' => 'PREFIXED']]; + + $this->assertSame('leaf[]', $this->render('{{ partial:shell handle_prefix="hero_" :d="d" }}', $data)); + $this->assertSame('leaf[]', $this->render('{{ scope:s handle_prefix="hero_" }}{{ include:leaf :params="d" }}{{ /scope:s }}', $data)); + $this->assertSame('leaf[]', $this->render('{{ include:leaf :params="d" }}', $data)); + } + + public function test_an_enclosing_handle_prefix_still_applies_to_slot_contents() + { + $this->viewShouldReturnRaw('shell', '{{ include:w }}[{{ title }}]{{ /include:w }}'); + $this->viewShouldReturnRaw('w', 'W<{{ slot }}>'); + + $this->assertSame('W<[T]>', $this->render('{{ partial:shell handle_prefix="hero_" :hero_title="t" }}', ['t' => 'T'])); + } + + public function test_a_deferred_slot_render_does_not_destroy_the_views_scope() + { + $this->viewShouldReturnRaw('outer', '[pre={{ av }}]<{{ slot }}>[post={{ av }}][params={{ params:av }}]'); + $this->viewShouldReturnRaw('inner', 'I<{{ slot }}>'); + + $this->assertSame( + '[pre=AV]>[post=AV][params=AV]', + $this->render('{{ include:outer av="AV" }}{{ include:inner }}X{{ /include:inner }}{{ /include:outer }}') + ); + + $this->assertSame( + '[pre=AV]>[post=AV][params=AV]', + $this->render('{{ include:outer av="AV" }}{{ partial:inner }}X{{ /partial:inner }}{{ /include:outer }}') + ); + } + + public function test_a_partial_inside_an_include_cannot_see_the_outer_caller_scope() + { + $this->viewShouldReturnRaw('shell', 'S[{{ p }}]{{ partial:inner }}'); + $this->viewShouldReturnRaw('inner', 'P[{{ p }}]'); + + $this->assertSame( + 'S[param]P[param]', + $this->render('{{ include:shell p="param" }}', ['p' => 'caller-p']) + ); + } + + public function test_a_partials_assignment_cannot_escape_the_include_boundary() + { + $this->viewShouldReturnRaw('shell', '{{ partial:setter }}IN[{{ v }}]'); + $this->viewShouldReturnRaw('setter', '{{ v = "from-partial" }}'); + + $this->assertSame( + 'IN[]|OUT[caller]', + $this->render('{{ v = "caller" }}{{ include:shell }}|OUT[{{ v }}]') + ); + } + + public function test_the_internal_slot_carrier_key_is_not_exposed_to_the_view() + { + $this->viewShouldReturnRaw('v', 'C[{{ __statamic_include_slots }}]'); + + $this->assertSame('C[]', $this->render('{{ include:v }}body{{ /include:v }}')); + } + + public function test_mutating_a_passed_array_does_not_affect_the_caller() + { + $this->viewShouldReturnRaw('mut', '{{ data:key = "mutated" }}IN[{{ data:key }}]'); + + $this->assertSame( + 'IN[mutated]|OUT[original]', + $this->render('{{ include:mut :data="data" }}|OUT[{{ data:key }}]', ['data' => ['key' => 'original']]) + ); + } + + public function test_mutating_a_passed_object_does_not_affect_the_caller() + { + $obj = new \stdClass(); + $obj->prop = 'original'; + + $this->viewShouldReturnRaw('omut', '{{ o:prop = "mutated" }}IN[{{ o:prop }}]'); + + $result = $this->render('{{ include:omut :o="o" }}|OUT[{{ o:prop }}]', ['o' => $obj]); + + $this->assertSame('IN[]|OUT[original]', $result); + $this->assertSame('original', $obj->prop, 'The underlying PHP object must not be mutated.'); + } + + public function test_self_closing_slot_output_avoids_same_name_pairing() + { + $this->viewShouldReturnRaw('outer', '{{ slot:title /}}{{ include:inner }}{{ slot:title }}INNER{{ /slot:title }}{{ /include:inner }}'); + $this->viewShouldReturnRaw('inner', '{{ slot:title /}}'); + + $template = '{{ include:outer }}{{ slot:title }}OUTER{{ /slot:title }}{{ /include:outer }}'; + + $this->assertSame('OUTERINNER', $this->render($template)); + } + + public function test_slot_content_cannot_see_include_internal_variables() + { + $this->viewShouldReturnRaw('w', '{{ internal = "secret" }}{{ slot }}'); + + $this->assertSame('[]', $this->render('{{ include:w }}[{{ internal }}]{{ /include:w }}')); + } + + public function test_scope_tag_writes_are_visible_outside_the_include() + { + $this->viewShouldReturnRaw('writer', '{{ scope:smuggled }}{{ secret }}{{ /scope:smuggled }}W'); + + $this->assertSame( + 'SW|CALLER[S]', + $this->render('{{ include:writer secret="S" }}|CALLER[{{ smuggled:secret }}]') + ); + $this->assertSame('S', Cascade::get('smuggled')['secret']); + } + + public function test_an_escaped_slot_can_receive_props() + { + $this->viewShouldReturnRaw('define', '{{ scope:snippet }}{{# captured #}}{{ /scope:snippet }}'); + + $this->assertSame( + 'Hello, Alice! Hello, Bob!', + $this->render('{{ include:define }}Hello, {{ name }}!{{ /include:define }}{{ snippet:slot name="Alice" }} {{ snippet:slot name="Bob" }}') + ); + } + + public function test_a_slot_that_escapes_the_include_can_still_render_afterwards() + { + $this->viewShouldReturnRaw('w', '{{ internal = "view-secret" }}{{ scope:smuggled }}W{{ /scope:smuggled }}'); + + $this->assertSame( + 'W|LATER[BODY:O:]', + $this->render('{{ include:w }}BODY:{{ outer }}:{{ internal }}{{ /include:w }}|LATER[{{ smuggled:slot }}]', ['outer' => 'O']) + ); + } +} diff --git a/tests/Antlers/Runtime/Includes/SlotsTest.php b/tests/Antlers/Runtime/Includes/SlotsTest.php new file mode 100644 index 00000000000..07c1e070ce8 --- /dev/null +++ b/tests/Antlers/Runtime/Includes/SlotsTest.php @@ -0,0 +1,300 @@ +withFakeViews(); + } + + private function render($template, $data = []) + { + return $this->renderString($template, $data, true); + } + + private $spy; + + private function registerSpyTag(): void + { + $this->spy = new class extends Tags + { + public static $handle = 'spy'; + + public static $count = 0; + + public function index() + { + self::$count++; + + return ''; + } + }; + + $this->spy::$count = 0; + $this->spy::register(); + } + + public function test_default_slot() + { + $this->viewShouldReturnRaw('wrapper', '
{{ slot }}
'); + + $this->assertSame('
Body
', $this->render('{{ include:wrapper }}Body{{ /include:wrapper }}')); + } + + public function test_a_default_slot_may_be_passed_inline_as_a_param() + { + $this->viewShouldReturnRaw('greeting', '
{{ slot }}
'); + + $this->assertSame('
Hello
', $this->render('{{ include:greeting slot="Hello" }}')); + } + + public function test_if_slot_is_false_when_no_body_is_given() + { + $this->viewShouldReturnRaw('wrapper', '{{ if slot }}HAS{{ else }}NONE{{ /if }}'); + + $this->assertSame('NONE', $this->render('{{ include:wrapper }}{{ /include:wrapper }}')); + $this->assertSame('NONE', $this->render('{{ include:wrapper }}')); + $this->assertSame('NONE', $this->render('{{ include:wrapper }} {{ /include:wrapper }}')); + } + + public function test_default_slot_presence_does_not_bleed_between_includes() + { + $this->viewShouldReturnRaw('wrapper', '{{ if slot }}HAS{{ else }}NONE{{ /if }}'); + + $this->assertSame( + 'HAS|NONE', + $this->render('{{ include:wrapper }}body{{ /include:wrapper }}|{{ include:wrapper }}{{ /include:wrapper }}') + ); + } + + public function test_slot_sees_outer_scope_but_the_view_does_not() + { + $this->viewShouldReturnRaw('wrapper', '{{ title }}{{ slot }}'); + + $template = '{{ include:wrapper }}{{ title }}{{ /include:wrapper }}'; + + $this->assertSame('Caller', $this->render($template, ['title' => 'Caller'])); + } + + public function test_slot_content_with_text_around_a_pair_is_preserved() + { + $this->viewShouldReturnRaw('wrapper', '{{ slot }}'); + + $tpl = '{{ include:wrapper }}before{{ if show }}mid{{ /if }}after{{ /include:wrapper }}'; + + $this->assertSame('beforemidafter', $this->render($tpl, ['show' => true])); + } + + public function test_a_slot_containing_only_a_pair_is_considered_present() + { + $this->viewShouldReturnRaw('wrapper', '{{ if slot }}HAS[{{ slot }}]{{ else }}NONE{{ /if }}'); + + $tpl = '{{ include:wrapper }}{{ if show }}X{{ /if }}{{ /include:wrapper }}'; + + $this->assertSame('HAS[X]', $this->render($tpl, ['show' => true])); + $this->assertSame('HAS[]', $this->render($tpl, ['show' => false])); + } + + public function test_slot_content_can_access_include_params() + { + $this->viewShouldReturnRaw('wrapper', '
{{ slot }}
'); + + $template = '{{ include:wrapper :params="data" handle_prefix="card_" foo="named" }}{{ params:foo }}|{{ params:title }}{{ /include:wrapper }}'; + + $this->assertSame('
named|Title
', $this->render($template, [ + 'data' => ['foo' => 'spread', 'card_title' => 'Title'], + ])); + } + + public function test_named_slots() + { + $this->viewShouldReturnRaw('card', '{{ slot:header }}{{ slot }}'); + + $template = '{{ include:card }}{{ slot:header }}Title{{ /slot:header }}Body{{ /include:card }}'; + + $this->assertSame('TitleBody', $this->render($template)); + } + + public function test_named_slots_do_not_replace_same_named_params() + { + $this->viewShouldReturnRaw('card', '[{{ header }}][{{ slot:header }}][{{ params:header }}]'); + $this->viewShouldReturnRaw('card_b', '[{{ $header }}][][{{ $params[\'header\'] }}]', 'blade.php'); + + $antlers = '{{ include:card header="Data" }}{{ slot:header }}Slot{{ /slot:header }}{{ /include:card }}'; + $blade = 'Slot'; + + $this->assertSame('[Data][Slot][Data]', $this->render($antlers)); + $this->assertSame('[Data][Slot][Data]', Blade::render($blade)); + } + + public function test_a_named_slot_falls_back_to_the_views_default_when_not_provided() + { + $this->viewShouldReturnRaw('card', '{{ if slot:header }}{{ slot:header }}{{ else }}Default{{ /if }}'); + + $this->assertSame('Default', $this->render('{{ include:card }}Body{{ /include:card }}')); + $this->assertSame('Provided', $this->render('{{ include:card }}{{ slot:header }}Provided{{ /slot:header }}{{ /include:card }}')); + } + + public function test_named_slot_presence_is_false_when_empty() + { + $this->viewShouldReturnRaw('card', '{{ if slot:header }}YES{{ else }}NO{{ /if }}'); + + $this->assertSame('NO', $this->render('{{ include:card }}{{ slot:header }} {{ /slot:header }}{{ /include:card }}')); + } + + public function test_a_missing_named_slot_used_as_a_pair_renders_nothing() + { + $this->viewShouldReturnRaw('card', '{{ slot:header }}fallback{{ /slot:header }}'); + + $this->assertSame('', $this->render('{{ include:card }}body{{ /include:card }}')); + } + + public function test_forwarding_a_slot_to_a_nested_include_does_not_clobber_the_outer_one() + { + $this->viewShouldReturnRaw('outer', '{{ include:inner tag="I" :__statamic_include_slot_body="slot:body" }}|{{ slot:body }}'); + $this->viewShouldReturnRaw('inner', '{{ slot:body }}'); + + $this->assertSame( + '[I]|[O]', + $this->render('{{ include:outer tag="O" }}{{ slot:body }}[{{ params:tag }}]{{ /slot:body }}{{ /include:outer }}') + ); + } + + public function test_pipe_modifiers_can_be_applied_to_slots() + { + $this->viewShouldReturnRaw('wrapper', '<{{ slot | upper }}>'); + $this->viewShouldReturnRaw('card', '<{{ slot:header | upper }}>'); + $this->viewShouldReturnRaw('chained', '<{{ slot:header | reverse | upper }}>'); + + $this->assertSame('', $this->render('{{ include:wrapper }}body{{ /include:wrapper }}')); + $this->assertSame('', $this->render('{{ include:card }}{{ slot:header }}head{{ /slot:header }}{{ /include:card }}')); + $this->assertSame('', $this->render('{{ include:chained }}{{ slot:header }}head{{ /slot:header }}{{ /include:chained }}')); + } + + public function test_default_slot_params_are_passed_as_props() + { + $this->viewShouldReturnRaw('scoped', '<{{ slot :label="title" }}>'); + $this->viewShouldReturnRaw('wrapper', '<{{ slot ensure_right="!" }}>'); + + $this->assertSame('<[T]>', $this->render('{{ include:scoped title="T" }}[{{ label }}]{{ /include:scoped }}')); + $this->assertSame('', $this->render('{{ include:wrapper }}body[{{ ensure_right }}]{{ /include:wrapper }}')); + } + + public function test_named_slot_params_are_always_passed_as_props() + { + $this->viewShouldReturnRaw('row', '{{ slot:row :label="title" ensure_right="!" }}'); + $this->viewShouldReturnRaw('card', '{{ slot:head :title="heading" }}'); + + $this->assertSame('[t|!]', $this->render('{{ include:row title="t" }}{{ slot:row }}[{{ label }}|{{ ensure_right }}]{{ /slot:row }}{{ /include:row }}')); + $this->assertSame('[H]', $this->render('{{ include:card heading="H" }}{{ slot:head }}[{{ title }}]{{ /slot:head }}{{ /include:card }}')); + } + + public function test_scoped_slot_exposes_multiple_props() + { + $this->viewShouldReturnRaw('row', '{{ slot:row :label="title" :n="num" }}'); + + $template = '{{ include:row title="T" num="3" }}{{ slot:row }}[{{ label }}|{{ n }}]{{ /slot:row }}{{ /include:row }}'; + + $this->assertSame('[T|3]', $this->render($template)); + } + + public function test_a_scoped_slot_is_rendered_for_each_iteration_of_a_loop() + { + $this->viewShouldReturnRaw('list', '{{ rows }}<{{ slot:row :label="value" :i="count" }}>{{ /rows }}'); + + $template = '{{ include:list :rows="data" }}{{ slot:row }}{{ label }}#{{ i }}{{ /slot:row }}{{ /include:list }}'; + + $this->assertSame('', $this->render($template, ['data' => [['value' => 'a'], ['value' => 'b']]])); + } + + public function test_scoped_slot_props_combine_with_caller_scope() + { + $this->viewShouldReturnRaw('combo', '{{ slot:item :label="heading" }}'); + + $template = '{{ include:combo heading="VIEW" }}{{ slot:item }}[{{ label }}|{{ outer }}]{{ /slot:item }}{{ /include:combo }}'; + + $this->assertSame('[VIEW|OUT]', $this->render($template, ['outer' => 'OUT'])); + } + + public function test_scoped_slot_props_override_caller_variables() + { + $this->viewShouldReturnRaw('clash', '{{ slot:item :name="inner" }}'); + + $template = '{{ include:clash inner="FROM-VIEW" }}{{ slot:item }}[{{ name }}]{{ /slot:item }}{{ /include:clash }}'; + + $this->assertSame('[FROM-VIEW]', $this->render($template, ['name' => 'FROM-CALLER'])); + } + + public function test_unused_slots_are_not_rendered() + { + $this->registerSpyTag(); + $this->viewShouldReturnRaw('wrapper', 'no slot output'); + + $template = '{{ include:wrapper }}{{ spy }}{{ /include:wrapper }}'; + + $this->assertSame('no slot output', $this->render($template)); + $this->assertSame(0, $this->spy::$count); + } + + public function test_a_slot_is_rendered_each_time_it_is_output() + { + $this->registerSpyTag(); + $this->viewShouldReturnRaw('wrapper', '{{ slot }}{{ slot }}'); + + $template = '{{ include:wrapper }}{{ spy }}{{ /include:wrapper }}'; + + $this->render($template); + + $this->assertSame(2, $this->spy::$count); + } + + public function test_a_condition_checks_slot_presence_without_rendering_it() + { + $this->registerSpyTag(); + $this->viewShouldReturnRaw('wrapper', '{{ if slot }}HAS{{ else }}NONE{{ /if }}'); + + $this->assertSame('HAS', $this->render('{{ include:wrapper }}{{ spy }}{{ /include:wrapper }}')); + $this->assertSame(0, $this->spy::$count); + } + + public function test_a_scoped_slot_guarded_by_a_condition_renders_only_once_with_its_props() + { + $this->registerSpyTag(); + $this->viewShouldReturnRaw('list', '{{ if slot:row }}{{ slot:row :label="heading" }}{{ /if }}'); + + $template = '{{ include:list heading="H" }}{{ slot:row }}{{ spy }}[{{ label }}]{{ /slot:row }}{{ /include:list }}'; + + $this->assertSame('[H]', $this->render($template)); + $this->assertSame(1, $this->spy::$count); + } + + public function test_antlers_slots_can_be_rendered_by_blade_views() + { + $this->viewShouldReturnRaw('list', '{{ $title }}@foreach($rows as $row)@endforeach', 'blade.php'); + + $template = '{{ include:list :rows="rows" }}{{ slot:title }}Title{{ /slot:title }}{{ slot:item }}[{{ label }}]{{ /slot:item }}{{ /include:list }}'; + + $this->assertSame('Title[A][B]', $this->render($template, ['rows' => ['A', 'B']])); + } + + public function test_blade_slots_can_be_rendered_by_antlers_views() + { + $this->viewShouldReturnRaw('list', '{{ rows }}{{ slot:item :label="value" }}{{ /rows }}[{{ params:item }}]'); + + $template = '[{{ $label }}{{ $params[\'title\'] }}]'; + + $this->assertSame('[AT][BT][]', Blade::render($template, [ + 'rows' => [['value' => 'A'], ['value' => 'B']], + ])); + } +} diff --git a/tests/StaticCaching/IncludeNocacheTest.php b/tests/StaticCaching/IncludeNocacheTest.php new file mode 100644 index 00000000000..be1919b288a --- /dev/null +++ b/tests/StaticCaching/IncludeNocacheTest.php @@ -0,0 +1,196 @@ +set('cache.default', 'file'); + $app['config']->set('statamic.static_caching.strategy', 'half'); + $app['config']->set('statamic.antlers.guardedVariables', ['secret_thing']); + } + + private function setUpViews(string $layout, array $views) + { + $this->withFakeViews(); + $this->viewShouldReturnRaw('layout', '{{ template_content }}'); + $this->viewShouldReturnRaw('default', $layout); + + foreach ($views as $name => $contents) { + $this->viewShouldReturnRaw($name, $contents); + } + + return $this->createPage('about', ['with' => ['title' => 'Existing']]); + } + + private function setUpBladeViews(array $views) + { + foreach ($views as $name => $contents) { + $this->viewShouldReturnRaw($name, $contents, 'blade.php'); + } + + view()->addNamespace('compiled__views', storage_path('framework/views')); + } + + private function renderTwice(string $layout, array $views): array + { + $page = $this->setUpViews($layout, $views); + + $first = $this->get('/about')->assertOk()->content(); + + $page->set('title', 'Updated')->saveQuietly(); + + $second = $this->get('/about')->assertOk()->content(); + + return [trim($first), trim($second)]; + } + + #[Test] + public function a_nocache_region_inside_an_included_view_stays_dynamic() + { + $this->assertSame( + ['W[Existing]', 'W[Updated]'], + $this->renderTwice('{{ include:w }}', ['w' => 'W{{ nocache }}[{{ title }}]{{ /nocache }}']) + ); + } + + #[Test] + public function a_nocache_region_around_an_include_stays_dynamic() + { + $this->assertSame( + ['W[Existing]', 'W[Updated]'], + $this->renderTwice('{{ nocache }}{{ include:w :t="title" }}{{ /nocache }}', ['w' => 'W[{{ t }}]']) + ); + } + + #[Test] + public function a_nocache_region_inside_slot_contents_stays_dynamic() + { + $this->assertSame( + ['W[Existing]', 'W[Updated]'], + $this->renderTwice( + '{{ include:w }}{{ nocache }}[{{ title }}]{{ /nocache }}{{ /include:w }}', + ['w' => 'W{{ slot }}'] + ) + ); + } + + #[Test] + public function a_slot_inside_a_nocache_region_renders_with_its_captured_scope() + { + $this->assertSame( + ['W[Existing]', 'W[Existing]'], + $this->renderTwice( + '{{ include:w }}[{{ title }}]{{ /include:w }}', + ['w' => 'W{{ nocache }}{{ slot }}{{ /nocache }}'] + ) + ); + } + + #[Test] + public function a_named_slot_inside_an_antlers_nocache_region_stays_dynamic() + { + $page = $this->setUpViews( + '{{ include:w :title="title" }}{{ slot:row }}{{ if label }}[{{ label | upper }}]{{ /if }}{{ /slot:row }}{{ /include:w }}', + ['w' => '{{ nocache }}{{ slot:row :label="title" }}{{ /nocache }}'] + ); + + $this->assertSame('[EXISTING]', trim($this->get('/about')->assertOk()->content())); + + $page->set('title', 'Updated')->saveQuietly(); + + $this->assertSame('[UPDATED]', trim($this->get('/about')->assertOk()->content())); + } + + #[Test] + public function a_scoped_slot_inside_a_nocache_region_stays_dynamic() + { + $page = $this->setUpViews( + '{{ include:w :title="title" }}{{ slot:row }}{{ if label }}[{{ label | upper }}]{{ /if }}{{ /slot:row }}{{ /include:w }}', + [] + ); + $this->setUpBladeViews(['w' => '']); + + $this->assertSame('[EXISTING]', trim($this->get('/about')->assertOk()->content())); + + $page->set('title', 'Updated')->saveQuietly(); + + $this->assertSame('[UPDATED]', trim($this->get('/about')->assertOk()->content())); + } + + #[Test] + public function escaped_antlers_inside_a_revived_slot_stays_escaped() + { + $this->assertSame( + ['A{{ x }}B', 'A{{ x }}B'], + $this->renderTwice( + '{{ include:w }}{{ slot:row }}A@{{ x }}B{{ /slot:row }}{{ /include:w }}', + ['w' => '{{ nocache }}{{ slot:row }}{{ /nocache }}'] + ) + ); + } + + #[Test] + public function a_tag_inside_a_revived_slot_can_parse_its_content() + { + $this->assertSame( + ['A[Existing]B', 'A[Existing]B'], + $this->renderTwice( + '{{ include:w }}{{ slot:row }}A{{ cache }}[{{ title }}]{{ /cache }}B{{ /slot:row }}{{ /include:w }}', + ['w' => '{{ nocache }}{{ slot:row }}{{ /nocache }}'] + ) + ); + } + + #[Test] + public function a_revived_slot_keeps_runtime_variable_guards() + { + $page = $this->setUpViews( + '{{ include:w }}{{ slot:row }}X{{ secret_thing }}Y{{ /slot:row }}{{ /include:w }}', + [] + ); + $page->set('secret_thing', 'S3CRET')->saveQuietly(); + $this->setUpBladeViews(['w' => '']); + + $this->assertSame('XY', trim($this->get('/about')->assertOk()->content())); + + // Simulate a fresh process, where nothing has resolved the parser binding yet. + GlobalRuntimeState::$bannedVarPaths = []; + GlobalRuntimeState::$bannedContentVarPaths = []; + + $this->assertSame('XY', trim($this->get('/about')->assertOk()->content())); + } + + #[Test] + public function a_forwarded_slot_inside_a_nocache_region_survives_replay() + { + $page = $this->setUpViews( + '{{ include:middle }}{{ slot:leaf }}[{{ title }}]{{ /slot:leaf }}{{ /include:middle }}', + [] + ); + $this->setUpBladeViews([ + 'middle' => '', + 'inner' => '', + ]); + + $this->assertSame('[Existing]', trim($this->get('/about')->assertOk()->content())); + + $page->set('title', 'Updated')->saveQuietly(); + + $this->assertSame('[Existing]', trim($this->get('/about')->assertOk()->content())); + } +} diff --git a/tests/View/Blade/AntlersComponents/IncludeCompilerTest.php b/tests/View/Blade/AntlersComponents/IncludeCompilerTest.php new file mode 100644 index 00000000000..b0c07996b19 --- /dev/null +++ b/tests/View/Blade/AntlersComponents/IncludeCompilerTest.php @@ -0,0 +1,403 @@ +withFakeViews(); + $this->artisan('view:clear'); + } + + #[Test] + public function it_compiles_include_tags() + { + $this->viewShouldReturnRaw('alert', '
{{ $title }}
', 'blade.php'); + + $expected = '
The Title
'; + + $this->assertSame($expected, Blade::render('')); + $this->assertSame($expected, Blade::render('')); + } + + #[Test] + public function it_compiles_include_tags_with_a_src_parameter() + { + $this->viewShouldReturnRaw('alert', '
{{ $title }}
', 'blade.php'); + + $this->assertSame('
The Title
', Blade::render('')); + } + + #[Test] + public function it_compiles_a_view_named_index() + { + $this->viewShouldReturnRaw('index', 'IDX'); + + $this->assertSame('IDX', Blade::render('')); + $this->assertSame('IDX', Blade::render('')); + } + + #[Test] + public function it_does_not_capture_the_caller_scope() + { + $this->viewShouldReturnRaw('alert', '[{{ $secret ?? "none" }}][{{ $passed ?? "none" }}]'); + + $this->assertSame( + '[none][yes]', + Blade::render('', ['secret' => 'LEAK']) + ); + } + + #[Test] + public function it_compiles_slots() + { + $this->viewShouldReturnRaw('alert', '
{{ $slot }}
'); + + $template = <<<'BLADE' + + I am the slot content. + +BLADE; + + $this->assertSame('
I am the slot content.
', Blade::render($template)); + $this->assertSame( + '
Title
', + Blade::render('{{ $params[\'title\'] }}') + ); + } + + #[Test] + public function slot_content_sees_the_caller_scope() + { + $this->viewShouldReturnRaw('alert', '
{{ $slot }}
'); + + $this->assertSame( + '
LEAK
', + Blade::render('{{ $secret }}', ['secret' => 'LEAK']) + ); + } + + #[Test] + public function it_compiles_named_slots() + { + $alert = <<<'ALERT' + +
{{ $slot }}
+ +ALERT; + $this->viewShouldReturnRaw('alert', $alert, 'blade.php'); + + $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_compiles_scoped_slots() + { + $this->viewShouldReturnRaw('list', "@foreach(\$rows as \$person)iteration\" />@endforeach", 'blade.php'); + + $template = <<<'BLADE' + + [{{ $name }}#{{ $index }}] + +BLADE; + + $this->assertSame( + '[Alice#1][Bob#2]', + Blade::render($template, ['people' => [['name' => 'Alice'], ['name' => 'Bob']]]) + ); + } + + #[Test] + public function a_named_slot_can_be_output_with_the_slot_tag() + { + $this->viewShouldReturnRaw('card', '
', 'blade.php'); + + $this->assertSame( + '
Hi
', + Blade::render('Hi') + ); + $this->assertSame('
', Blade::render('')); + } + + #[Test] + public function a_paired_named_slot_falls_back_to_its_body_when_the_slot_is_not_provided() + { + $this->viewShouldReturnRaw('card', '
Default footer
', 'blade.php'); + + $this->assertSame('
Default footer
', Blade::render('')); + } + + #[Test] + public function a_paired_named_slot_renders_the_provided_slot_instead_of_its_body() + { + $this->viewShouldReturnRaw('card', '
Default footer
', 'blade.php'); + + $this->assertSame( + '
Provided
', + Blade::render('Provided') + ); + } + + #[Test] + public function a_paired_default_slot_falls_back_to_its_body_when_the_slot_is_not_provided() + { + $this->viewShouldReturnRaw('card', '
Default body
', 'blade.php'); + + $this->assertSame('
Default body
', Blade::render('')); + $this->assertSame('
Provided
', Blade::render('Provided')); + } + + #[Test] + public function it_forwards_exists_method_calls() + { + $template = 'Yes'; + + $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 = ''; + + $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 = ''; + + $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 = ''; + + $this->assertSame('', Blade::render($template, ['theValue' => true])); + $this->assertSame('The content', Blade::render($template, ['theValue' => false])); + } + + #[Test] + public function it_isolates_the_caller_scope_through_nesting() + { + $this->viewShouldReturnRaw('outer', 'O[{{ $a ?? "none" }}]', 'blade.php'); + $this->viewShouldReturnRaw('inner', 'I[{{ $a ?? "none" }}]', 'blade.php'); + + $this->assertSame('O[none]I[none]', Blade::render('', ['a' => 'CALLER'])); + } + + #[Test] + public function a_param_does_not_leak_into_the_next_include() + { + $this->viewShouldReturnRaw('card', 'C[{{ $class ?? "none" }}]', 'blade.php'); + + $this->assertSame( + 'C[cool]C[none]', + Blade::render('') + ); + } + + #[Test] + public function an_assignment_inside_an_include_does_not_leak_to_a_sibling() + { + $this->viewShouldReturnRaw('setter', '{{ v = "set" }}S'); + $this->viewShouldReturnRaw('getter', 'G[{{ v ?? "none" }}]'); + + $this->assertSame('SG[none]', Blade::render('')); + } + + #[Test] + public function it_compiles_nested_includes() + { + $this->viewShouldReturnRaw('one', 'Just Some Text'); + $this->viewShouldReturnRaw('two', '{{ $slot }}'); + + $template = <<<'BLADE' + + + +BLADE; + + $this->assertSame('Just Some Text', trim(Blade::render($template))); + } + + #[Test] + public function slot_tags_still_compile_outside_includes() + { + (new class extends Tags + { + protected static $handle = 'slot'; + + public function wildcard($tag) + { + return 'custom'; + } + })::register(); + + $this->assertSame('custom', Blade::render('', ['header' => 'data'])); + + $this->assertSame('custom', Blade::render('')); + } + + #[Test] + public function invalid_slot_names_are_rejected() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid slot name [bad-name]'); + + Blade::render('Bad'); + } + + #[Test] + public function a_slot_does_not_replace_the_variables_the_include_provides() + { + $this->viewShouldReturnRaw('card', "@frontmatter(['fm' => 'F'])[{{ \$params['title'] }}][{{ \$view['fm'] }}][][]", 'blade.php'); + + $template = <<<'BLADE' + + P + V + +BLADE; + + $this->assertSame('[T][F][P][V]', trim(Blade::render($template))); + } + + #[Test] + public function a_partial_inside_an_include_resolves_its_own_slots() + { + $this->viewShouldReturnRaw('card', 'FromPartial', 'blade.php'); + $this->viewShouldReturnRaw('badge', '[{{ $label }}]', 'blade.php'); + + $this->assertSame( + '[FromPartial]', + Blade::render('FromInclude') + ); + } + + #[Test] + public function the_if_exists_default_slot_is_lazy() + { + $spy = new class extends Tags + { + protected static $handle = 'if_exists_spy'; + + public static $count = 0; + + public function index() + { + self::$count++; + + return 'SPY'; + } + }; + $spy::register(); + + $this->viewShouldReturnRaw('ignores_slot', 'Card', 'blade.php'); + $this->viewShouldReturnRaw('uses_slot', 'Card[{{ $slot }}]', 'blade.php'); + + $spy::$count = 0; + $this->assertSame('Card', Blade::render('')); + $this->assertSame(0, $spy::$count); + + $spy::$count = 0; + $this->assertSame('Card[SPY]', Blade::render('')); + $this->assertSame(1, $spy::$count); + } + + #[Test] + public function unused_default_slots_are_not_rendered() + { + $spy = new class extends Tags + { + protected static $handle = 'include_spy'; + + public static $count = 0; + + public function index() + { + self::$count++; + + return ''; + } + }; + + $spy::register(); + $this->viewShouldReturnRaw('card', 'Card', 'blade.php'); + + $this->assertSame('Card', Blade::render('')); + $this->assertSame(0, $spy::$count); + } + + #[Test] + public function slots_named_after_framework_variables_are_not_aliased() + { + $this->viewShouldReturnRaw('card_env', "@forelse ([1] as \$i)\nok\n@empty\nx\n@endforelse\n[]", 'blade.php'); + + $this->assertSame('ok [E]', Str::squish(Blade::render('E'))); + } + + #[Test] + public function slot_content_containing_the_hoisted_nowdoc_terminator_compiles() + { + $this->viewShouldReturnRaw('alert', '
{{ $slot }}
', 'blade.php'); + + $this->assertSame( + "
before\nCOMPILED;\nafter
", + Blade::render("before\nCOMPILED;\nafter") + ); + } + + #[Test] + public function a_whitespace_only_body_is_not_a_slot() + { + $this->viewShouldReturnRaw('wrapper', '{{ if slot }}HAS{{ else }}NONE{{ /if }}'); + + $this->assertSame('NONE', Blade::render("\n \n")); + } +} diff --git a/tests/View/SlotTest.php b/tests/View/SlotTest.php new file mode 100644 index 00000000000..677b9508708 --- /dev/null +++ b/tests/View/SlotTest.php @@ -0,0 +1,111 @@ + implode(',', array_filter([ + $data['outer'] ?? null, + $data['params']['title'] ?? null, + $data['n'] ?? null, + ])), ['outer' => 'O']); + + $slot->withParams(['title' => 'T']); + + $this->assertSame('O,T', $slot->render()); + $this->assertSame('O,T,3', $slot->render(['n' => '3'])); + } + + public function test_a_closure_slot_serializes_as_rendered_content() + { + $slot = new Slot(fn ($data) => 'rendered:'.($data['outer'] ?? ''), ['outer' => 'O']); + + $revived = unserialize(serialize($slot)); + + $this->assertSame('rendered:O', $revived->render()); + $this->assertSame('rendered:O', $revived->toHtml()); + } + + public function test_a_revived_closure_slot_rejects_props() + { + $slot = (new Slot(fn ($data) => 'x', []))->named('row'); + + $revived = unserialize(serialize($slot)); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('The [row] slot has already been rendered and cached'); + + $revived->render(['n' => '3']); + } + + public function test_a_revived_blade_slot_renders_with_its_data_params_and_props() + { + $slot = Slot::forBlade('{{ $outer ?? "?" }},{{ $params["title"] ?? "?" }},{{ $n ?? "?" }}', ['outer' => 'O']) + ->withParams(['title' => 'T']); + + $revived = unserialize(serialize($slot)); + + $this->assertSame('O,T,?', $revived->render()); + $this->assertSame('O,T,3', $revived->render(['n' => '3'])); + } + + public function test_nested_slots_survive_serialization() + { + $inner = Slot::forBlade('inner:{{ $x ?? "?" }}', ['x' => 'X']); + $outer = Slot::forBlade('outer({{ $nested }})', ['nested' => $inner]); + + $revived = unserialize(serialize($outer)); + + $this->assertSame('outer(inner:X)', $revived->render()); + } + + public function test_a_revived_slot_survives_another_serialization_round_trip() + { + $slot = Slot::forBlade('{{ $outer }},{{ $n ?? "?" }}', ['outer' => 'O']); + + $twice = unserialize(serialize(unserialize(serialize($slot)))); + + $this->assertSame('O,3', $twice->render(['n' => '3'])); + } + + public function test_component_scope_variables_are_resolved_when_cached() + { + $slot = Slot::forBlade('{{ $label }}', ['label' => new InvokableComponentVariable(fn () => 'V')]); + + $revived = unserialize(serialize($slot)); + + $this->assertSame('V', $revived->render()); + } + + public function test_slot_content_matching_a_view_name_renders_as_content() + { + $this->withFakeViews(); + $this->viewShouldReturnRaw('footer', 'THE VIEW', 'blade.php'); + + $slot = Slot::forBlade('footer', []); + + $this->assertSame('footer', $slot->render()); + $this->assertSame('footer', unserialize(serialize($slot))->render()); + } + + public function test_a_slot_with_unserializable_scope_throws_when_cached() + { + $slot = Slot::forBlade('{{ $outer ?? "?" }}', ['outer' => 'O', 'bad' => fn () => null]) + ->named('row'); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('The [row] slot cannot be cached because its scope contains values that cannot be serialized (bad)'); + + serialize($slot); + } +}