diff --git a/src/View/Antlers/Language/Runtime/NodeProcessor.php b/src/View/Antlers/Language/Runtime/NodeProcessor.php index 59f964c51b9..632bc5142e8 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 @@ -110,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. * @@ -291,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. * @@ -967,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. * @@ -1802,7 +1840,9 @@ public function reduce($processNodes) $output = RuntimeValues::resolveWithRuntimeIsolation($output); } - $output = PathDataManager::reduceForAntlers($output, $this->antlersParser, $this->getActiveData(), $node->isClosedBy != null); + if (! $this->assigningAugmentable($output)) { + $output = PathDataManager::reduceForAntlers($output, $this->antlersParser, $this->getActiveData(), $node->isClosedBy != null); + } } if ($this->isInterpolationProcessor) { @@ -2512,6 +2552,13 @@ public function reduce($processNodes) return $buffer; } + private function assigningAugmentable($output): bool + { + return $this->isAssignmentProcessor + && $output instanceof Augmentable + && $output instanceof Stringable; + } + /** * Executes any PHP within the provided buffer and returns the result. * 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. * diff --git a/tests/Antlers/Runtime/TagsTest.php b/tests/Antlers/Runtime/TagsTest.php index 2636dc3938b..b293de1ba89 100644 --- a/tests/Antlers/Runtime/TagsTest.php +++ b/tests/Antlers/Runtime/TagsTest.php @@ -2,6 +2,13 @@ namespace Tests\Antlers\Runtime; +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; @@ -46,4 +53,105 @@ 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() + { + $this->createAsset(); + + $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))); + } + + 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(); + } }