From cded9d9288cacbde0b42512e898ef30ac59ef1dd Mon Sep 17 00:00:00 2001 From: Duncan McClean Date: Tue, 8 Sep 2026 12:04:52 +0100 Subject: [PATCH 1/3] keep augmentable tag results as objects inside antlers interpolations Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01YENbpFdMXXA4j68iM7Dog2 --- .../Language/Runtime/NodeProcessor.php | 12 +++++++++- tests/Antlers/Runtime/TagsTest.php | 22 +++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/src/View/Antlers/Language/Runtime/NodeProcessor.php b/src/View/Antlers/Language/Runtime/NodeProcessor.php index 59f964c51b9..593562458d5 100644 --- a/src/View/Antlers/Language/Runtime/NodeProcessor.php +++ b/src/View/Antlers/Language/Runtime/NodeProcessor.php @@ -57,6 +57,7 @@ use Statamic\View\Cascade; use Statamic\View\Slot; use Statamic\View\State\CachesOutput; +use Stringable; use Throwable; class NodeProcessor @@ -1802,7 +1803,9 @@ public function reduce($processNodes) $output = RuntimeValues::resolveWithRuntimeIsolation($output); } - $output = PathDataManager::reduceForAntlers($output, $this->antlersParser, $this->getActiveData(), $node->isClosedBy != null); + if (! $this->interpolatingAugmentable($output)) { + $output = PathDataManager::reduceForAntlers($output, $this->antlersParser, $this->getActiveData(), $node->isClosedBy != null); + } } if ($this->isInterpolationProcessor) { @@ -2512,6 +2515,13 @@ public function reduce($processNodes) return $buffer; } + private function interpolatingAugmentable($output): bool + { + return $this->isInterpolationProcessor + && $output instanceof Augmentable + && $output instanceof Stringable; + } + /** * Executes any PHP within the provided buffer and returns the result. * diff --git a/tests/Antlers/Runtime/TagsTest.php b/tests/Antlers/Runtime/TagsTest.php index 2636dc3938b..f707d037439 100644 --- a/tests/Antlers/Runtime/TagsTest.php +++ b/tests/Antlers/Runtime/TagsTest.php @@ -2,6 +2,10 @@ namespace Tests\Antlers\Runtime; +use Illuminate\Http\UploadedFile; +use Illuminate\Support\Facades\Storage; +use Statamic\Facades\Asset; +use Statamic\Facades\AssetContainer; use Statamic\Tags\Tags; use Tests\Antlers\ParserTestCase; @@ -46,4 +50,22 @@ public function index() $this->assertSame('b', $this->renderString('{{ test_tag }}{{ a }}{{ /test_tag }}', [], true)); } + + /** + * @see https://github.com/statamic/cms/issues/11257 + */ + public function test_objects_returned_from_tags_keep_their_data_when_assigned_to_a_variable() + { + Storage::fake('test', ['url' => '/assets']); + Storage::disk('test')->put('a.jpg', UploadedFile::fake()->image('a.jpg')->getContent()); + tap(AssetContainer::make('test')->disk('test'))->save(); + Asset::find('test::a.jpg')->data(['alt' => 'Alpha'])->save(); + + $template = <<<'EOT' +{{ img = { asset url="/assets/a.jpg" } }} +{{ img }}|{{ img.url }}|{{ img.alt }}|{{ img:alt }} +EOT; + + $this->assertSame('/assets/a.jpg|/assets/a.jpg|Alpha|Alpha', trim($this->renderString($template, [], true))); + } } From 4982cbdd94c09f6471c7e0c5cfda77a600fbf066 Mon Sep 17 00:00:00 2001 From: Duncan McClean Date: Mon, 14 Sep 2026 08:45:01 +0100 Subject: [PATCH 2/3] only keep tag objects when an interpolation is assigned to a variable `isInterpolationProcessor` is shared by every interpolation consumer, so keeping objects behind it leaked an `Asset` into dynamic key lookups, condition operands and expressions. the object now survives only when the interpolation is the right-hand side of an assignment, via `Environment::getAssignedValue()` and `NodeProcessor::reduceAssignedInterpolatedVariable()`. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_017G87jCB5KrA8cTTfmrkAWM --- .../Language/Runtime/NodeProcessor.php | 43 +++++++++++++++++-- .../Language/Runtime/Sandbox/Environment.php | 11 ++++- 2 files changed, 50 insertions(+), 4 deletions(-) diff --git a/src/View/Antlers/Language/Runtime/NodeProcessor.php b/src/View/Antlers/Language/Runtime/NodeProcessor.php index 593562458d5..632bc5142e8 100644 --- a/src/View/Antlers/Language/Runtime/NodeProcessor.php +++ b/src/View/Antlers/Language/Runtime/NodeProcessor.php @@ -111,6 +111,13 @@ class NodeProcessor */ protected $isInterpolationProcessor = false; + /** + * Indicates if the processor is reducing a value being assigned to a variable. + * + * @var bool + */ + protected $isAssignmentProcessor = false; + /** * Indicates if the processor is providing results for a parameter. * @@ -292,6 +299,19 @@ public function setIsInterpolationProcessor($isInterpolation) return $this; } + /** + * Sets whether the NodeProcessor is reducing a value being assigned to a variable. + * + * @param bool $isAssignment The value. + * @return $this + */ + public function setIsAssignmentProcessor($isAssignment) + { + $this->isAssignmentProcessor = $isAssignment; + + return $this; + } + /** * Sets whether the NodeProcessor is processing conditions. * @@ -968,6 +988,23 @@ public function reduceInterpolatedVariable(VariableNode $node) return $this->interpolationCache[$node->name]; } + /** + * Evaluates an interpolated variable being assigned to a variable, keeping tag objects intact. + * + * @param VariableNode $node The interpolated variable. + * @return mixed + * + * @throws RuntimeException + * @throws SyntaxErrorException + */ + public function reduceAssignedInterpolatedVariable(VariableNode $node) + { + return $this->cloneProcessor() + ->setIsInterpolationProcessor(true) + ->setIsAssignmentProcessor(true) + ->setData($this->getActiveData())->reduce($node->interpolationNodes); + } + /** * Executes the requested tag within the context of the current processor and provided node. * @@ -1803,7 +1840,7 @@ public function reduce($processNodes) $output = RuntimeValues::resolveWithRuntimeIsolation($output); } - if (! $this->interpolatingAugmentable($output)) { + if (! $this->assigningAugmentable($output)) { $output = PathDataManager::reduceForAntlers($output, $this->antlersParser, $this->getActiveData(), $node->isClosedBy != null); } } @@ -2515,9 +2552,9 @@ public function reduce($processNodes) return $buffer; } - private function interpolatingAugmentable($output): bool + private function assigningAugmentable($output): bool { - return $this->isInterpolationProcessor + return $this->isAssignmentProcessor && $output instanceof Augmentable && $output instanceof Stringable; } diff --git a/src/View/Antlers/Language/Runtime/Sandbox/Environment.php b/src/View/Antlers/Language/Runtime/Sandbox/Environment.php index 76163a5585d..aa0065f5d4b 100644 --- a/src/View/Antlers/Language/Runtime/Sandbox/Environment.php +++ b/src/View/Antlers/Language/Runtime/Sandbox/Environment.php @@ -978,7 +978,7 @@ public function process($nodes) if ($operand instanceof LeftAssignmentOperator) { $varName = $this->nameOf($left); - $right = $this->checkForFieldValue($this->getValue($rightNode)); + $right = $this->checkForFieldValue($this->getAssignedValue($rightNode)); $this->dataRetriever->setRuntimeValue($varName, $this->data, $right); $lastPath = $this->dataRetriever->lastPath(); @@ -1177,6 +1177,15 @@ public function process($nodes) return $stack; } + private function getAssignedValue($node) + { + if ($node instanceof VariableNode && $node->isInterpolationReference && ! $node->hasModifiers()) { + return $this->nodeProcessor->reduceAssignedInterpolatedVariable($node); + } + + return $this->getValue($node); + } + /** * Evaluates the provided null coalescence group. * From 2cd8a08f62bfc0fb0fcfdf472e43390d887b4fdb Mon Sep 17 00:00:00 2001 From: Duncan McClean Date: Mon, 14 Sep 2026 08:45:04 +0100 Subject: [PATCH 3/3] cover interpolated tag objects in dynamic keys, conditions and assignments Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_017G87jCB5KrA8cTTfmrkAWM --- tests/Antlers/Runtime/TagsTest.php | 94 ++++++++++++++++++++++++++++-- 1 file changed, 90 insertions(+), 4 deletions(-) diff --git a/tests/Antlers/Runtime/TagsTest.php b/tests/Antlers/Runtime/TagsTest.php index f707d037439..b293de1ba89 100644 --- a/tests/Antlers/Runtime/TagsTest.php +++ b/tests/Antlers/Runtime/TagsTest.php @@ -4,8 +4,11 @@ use Illuminate\Http\UploadedFile; use Illuminate\Support\Facades\Storage; +use PHPUnit\Framework\Attributes\DataProvider; use Statamic\Facades\Asset; use Statamic\Facades\AssetContainer; +use Statamic\Fields\Value; +use Statamic\Fields\Values; use Statamic\Tags\Tags; use Tests\Antlers\ParserTestCase; @@ -56,10 +59,7 @@ public function index() */ public function test_objects_returned_from_tags_keep_their_data_when_assigned_to_a_variable() { - Storage::fake('test', ['url' => '/assets']); - Storage::disk('test')->put('a.jpg', UploadedFile::fake()->image('a.jpg')->getContent()); - tap(AssetContainer::make('test')->disk('test'))->save(); - Asset::find('test::a.jpg')->data(['alt' => 'Alpha'])->save(); + $this->createAsset(); $template = <<<'EOT' {{ img = { asset url="/assets/a.jpg" } }} @@ -68,4 +68,90 @@ public function test_objects_returned_from_tags_keep_their_data_when_assigned_to $this->assertSame('/assets/a.jpg|/assets/a.jpg|Alpha|Alpha', trim($this->renderString($template, [], true))); } + + public function test_objects_returned_from_tags_can_be_looped_over_after_being_assigned_to_a_variable() + { + $this->createAsset(); + + $template = <<<'EOT' +{{ img = { asset url="/assets/a.jpg" } }} +{{ img }}[{{ alt }}]{{ /img }} +EOT; + + $this->assertSame('[Alpha]', trim($this->renderString($template, [], true))); + } + + #[DataProvider('dynamicKeyProvider')] + public function test_objects_returned_from_tags_are_strings_when_used_as_dynamic_keys($template) + { + $this->createAsset(); + + $this->assertSame('matched', $this->renderString($template, ['items' => ['/assets/a.jpg' => 'matched']], true)); + } + + public static function dynamicKeyProvider() + { + return [ + 'dot syntax' => ['{{ items.{asset url="/assets/a.jpg"} }}'], + 'bracket syntax' => ['{{ items[{asset url="/assets/a.jpg"}] }}'], + ]; + } + + #[DataProvider('comparisonProvider')] + public function test_objects_returned_from_tags_are_strings_when_compared_in_conditions($template, $expected) + { + $this->createAsset(); + + $this->assertSame($expected, $this->renderString($template, [], true)); + } + + public static function comparisonProvider() + { + return [ + 'identical' => ['{{ if {asset url="/assets/a.jpg"} === "/assets/a.jpg" }}yes{{ else }}no{{ /if }}', 'yes'], + 'not identical' => ['{{ if {asset url="/assets/a.jpg"} !== "/assets/a.jpg" }}yes{{ else }}no{{ /if }}', 'no'], + 'ternary' => ['{{ {asset url="/assets/a.jpg"} === "/assets/a.jpg" ? "yes" : "no" }}', 'yes'], + ]; + } + + public function test_values_returned_from_tags_are_unwrapped_when_assigned_to_a_variable() + { + (new class extends Tags + { + public static $handle = 'test_value'; + + public function index() + { + return new Value('wrapped'); + } + })::register(); + + (new class extends Tags + { + public static $handle = 'test_values'; + + public function index() + { + return new Values(['a' => 'b']); + } + })::register(); + + $this->assertSame('wrapped', $this->renderString('{{ value = {test_value} }}{{ value }}', [], true)); + $this->assertSame('b', $this->renderString('{{ values = {test_values} }}{{ values:a }}', [], true)); + } + + public function test_missing_assets_assign_nothing_to_a_variable() + { + $this->createAsset(); + + $this->assertSame('[]', $this->renderString('{{ img = { asset url="/assets/missing.jpg" } }}[{{ img }}]', [], true)); + } + + private function createAsset() + { + Storage::fake('test', ['url' => '/assets']); + Storage::disk('test')->put('a.jpg', UploadedFile::fake()->image('a.jpg')->getContent()); + tap(AssetContainer::make('test')->disk('test'))->save(); + Asset::find('test::a.jpg')->data(['alt' => 'Alpha'])->save(); + } }