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(
+ 'AB',
+ $this->render('{{ nav }}{{ include:item :t="title" }}{{ if children }}{{ *recursive children* }}
{{ /if }}{{ /nav }}', ['nav' => $this->tree()])
+ );
+
+ $this->assertSame(
+ '[A][B]',
+ $this->render('{{ include:menu :nav="tree" }}', ['tree' => $this->tree()])
+ );
+
+ $this->assertSame(
+ 'W[A][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(
+ '',
+ 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"));
+ }
+}