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/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..f368fa94a26 --- /dev/null +++ b/src/Tags/IncludeTag.php @@ -0,0 +1,297 @@ +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 = []; + + try { + return $view->with($scope) + ->withoutExtractions() + ->render(); + } finally { + 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->withParams($data); + } + + 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 Arr::except($spread, self::CONTROL); + } + + 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..3e91115f9d4 --- /dev/null +++ b/src/View/Antlers/Language/Runtime/Concerns/ManagesIncludeSlots.php @@ -0,0 +1,111 @@ +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'] = $this->makeSlot($defaultChildren, $callerData); + } + + foreach ($namedSlots as $slotName => $slotNode) { + if ($this->slotHasContent($slotNode->children)) { + $slots[$slotName] = $this->makeSlot($slotNode->children, $callerData); + } + } + + return $slots; + } + + protected function makeSlot(array $nodes, array $callerData): Slot + { + $callerState = [GlobalRuntimeState::$isCascadeEnabled, GlobalRuntimeState::$prefixState]; + + $renderer = function (array $data) use ($nodes, $callerState) { + $tagState = [GlobalRuntimeState::$isCascadeEnabled, GlobalRuntimeState::$prefixState]; + + [GlobalRuntimeState::$isCascadeEnabled, GlobalRuntimeState::$prefixState] = $callerState; + + try { + return $this->cloneProcessor()->setData($data)->reduce($nodes); + } finally { + [GlobalRuntimeState::$isCascadeEnabled, GlobalRuntimeState::$prefixState] = $tagState; + } + }; + + return new Slot($renderer, $callerData); + } + + 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..012abd2259f 100644 --- a/src/View/Antlers/Language/Runtime/GlobalRuntimeState.php +++ b/src/View/Antlers/Language/Runtime/GlobalRuntimeState.php @@ -251,6 +251,7 @@ public static function captureRuntimeState(): array self::$requiresRuntimeIsolation, self::$traceTagAssignments, self::$tracedRuntimeAssignments, + self::$isCascadeEnabled, ]; } @@ -265,16 +266,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/NodeProcessor.php b/src/View/Antlers/Language/Runtime/NodeProcessor.php index 1d7bf42b81a..17a7fa4bbd1 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); @@ -2153,6 +2161,17 @@ public function reduce($processNodes) $val = $val->get()->all(); } + if ($val instanceof Slot) { + $val = $val->render($node->hasParameters ? $this->getSlotOutputProps($node) : []); + $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/RuntimeParser.php b/src/View/Antlers/Language/Runtime/RuntimeParser.php index e8883b58a49..39b9f9546e8 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,13 @@ public function parseView($view, $text, $data = []) GlobalRuntimeState::$isEvaluatingUserData = false; $existingView = $this->view; + + $suspendedData = $this->nodeProcessor->getAllData(); + try { return $this->renderViewContent($view, $text, $data); } finally { + $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..99693bd653e 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,22 @@ protected function isSlotTag(string $tagName): bool return $tagName === 'slot' || str($tagName)->startsWith(['slot.', 'slot:']); } + private 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).')'; + + return ''.$this->compileComponent($component).''; + } + protected function isComponentSlot(ComponentNode $parent, ComponentNode $child): bool { return $child->parent === $parent && $this->isSlotTag($child->tagName); @@ -52,47 +70,96 @@ protected function compileSlot(ComponentNode $node): array return [$name, $compiled]; } + private 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)]; + } + + private function rawSlotName(ComponentNode $component): string + { + $name = (string) str($component->name)->substr(5); + + return $name === '' ? 'slot' : $name; + } + + private 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); + } + + private function compileInclude(ComponentNode $component): string + { + return $this->compileViewTag($component, isInclude: true); + } + + private 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 + ? 'new \Statamic\View\Slot(fn ($__slotData) => \Illuminate\Support\Facades\Blade::render($'.$hoistedVarName.', $__slotData), 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 +167,7 @@ protected function compilePartial(ComponentNode $component): string '$varName' => $hoistedVarName, ], $unset); - $params[$name] = $injectedParam; + $params[$paramName] = $injectedParam; } $compiledNode = <<<'PHP' @@ -134,8 +201,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 +214,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..651cfabaa19 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:'); } + private 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..6f0a7e8ac1c --- /dev/null +++ b/src/View/Slot.php @@ -0,0 +1,68 @@ +data, ['params' => $this->params], $props); + + return trim((string) ($this->renderer)($data)); + } + + public function withParams(array $params): static + { + $this->params = $params; + + return $this; + } + + public function toHtml(): string + { + return $this->render(); + } + + public function __serialize(): array + { + return ['rendered' => $this->render()]; + } + + public function __unserialize(array $data): void + { + $rendered = $data['rendered'] ?? ''; + + $this->renderer = fn () => $rendered; + $this->data = []; + $this->params = []; + } + + 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); + } +} 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..bbe3e2fde12 --- /dev/null +++ b/tests/Antlers/Runtime/Includes/IncludeTagTest.php @@ -0,0 +1,211 @@ +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_meta_params_never_appear_as_data() + { + $this->viewShouldReturnRaw('card', '[{{ params:src }}][{{ params:when }}][{{ params:handle_prefix }}][{{ params:params }}]'); + + $this->assertSame( + '[][][][]', + $this->render('{{ include:card :params="data" handle_prefix="x_" }}', ['data' => ['src' => 'sneaky', 'when' => 'sneaky']]) + ); + } + + 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" }}')); + } +} 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..9e7aede6264 --- /dev/null +++ b/tests/Antlers/Runtime/Includes/SandboxTest.php @@ -0,0 +1,149 @@ +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_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..456e2e54c92 --- /dev/null +++ b/tests/Antlers/Runtime/Includes/SlotsTest.php @@ -0,0 +1,253 @@ +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_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/View/Blade/AntlersComponents/IncludeCompilerTest.php b/tests/View/Blade/AntlersComponents/IncludeCompilerTest.php new file mode 100644 index 00000000000..cd5014a178f --- /dev/null +++ b/tests/View/Blade/AntlersComponents/IncludeCompilerTest.php @@ -0,0 +1,358 @@ +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_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 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")); + } +}