From 96a44927b53b16b84826292b30aac5ce574553c0 Mon Sep 17 00:00:00 2001 From: Julia Boutin Date: Tue, 21 Oct 2025 16:53:15 -0600 Subject: [PATCH] Support prop spreading --- lib/liquid/parser.rb | 8 + lib/liquid/tags/render.rb | 60 ++++-- test/integration/tags/snippet_test.rb | 274 ++++++++++++++++++++++++++ 3 files changed, 329 insertions(+), 13 deletions(-) diff --git a/lib/liquid/parser.rb b/lib/liquid/parser.rb index 645dfa3a..93cda908 100644 --- a/lib/liquid/parser.rb +++ b/lib/liquid/parser.rb @@ -12,6 +12,14 @@ module Liquid @p = point end + def read(type = nil) + token = @tokens[@p] + if type && token[0] != type + raise SyntaxError, "Expected #{type} but found #{@tokens[@p].first}" + end + token[1] + end + def consume(type = nil) token = @tokens[@p] if type && token[0] != type diff --git a/lib/liquid/tags/render.rb b/lib/liquid/tags/render.rb index 3e9237f7..177c8f4d 100644 --- a/lib/liquid/tags/render.rb +++ b/lib/liquid/tags/render.rb @@ -76,10 +76,25 @@ module Liquid inner_context['forloop'] = forloop if forloop @attributes.each do |key, value| - if key == "..." && is_inline - context.scopes.each do |scope| - scope.each do |k, v| - inner_context[k] = v + if key.start_with?("...") && is_inline + if key == "..." + context.scopes.each do |scope| + scope.each do |k, v| + inner_context[k] = v + end + end + else + obj = context.evaluate(value) + if obj.is_a?(Liquid::Drop) + (obj.class.invokable_methods - ['to_liquid']).each do |method_name| + inner_context[method_name] = obj.invoke_drop(method_name) + end + elsif obj.is_a?(Hash) + obj.each do |k, v| + inner_context[k] = v + end + else + raise ::ArgumentError end end else @@ -117,11 +132,24 @@ module Liquid p.consume?(:comma) @attributes = {} - while p.look(:id) - key = p.consume - p.consume(:colon) - @attributes[key] = safe_parse_expression(p) - p.consume?(:comma) + while p.look(:dotdotdot) || p.look(:id) + if p.consume?(:dotdotdot) + if p.look(:id) + identifier = p.read(:id) + key = "...#{identifier}" + @attributes.delete(key) + @attributes[key] = safe_parse_expression(p) + else + @attributes.delete("...") + @attributes["..."] = true + end + else + key = p.consume + p.consume(:colon) + @attributes.delete(key) + @attributes[key] = safe_parse_expression(p) + end + p.consume?(:comma) # optional comma end p.consume(:end_of_string) @@ -150,10 +178,16 @@ module Liquid @is_for_loop = (with_or_for == FOR) @attributes = {} - markup.scan(/(\.\.\.)(?=\s|,|$)|#{TagAttributes.source}/) do |context_marker, key, value| - if context_marker - @attributes.delete("...") - @attributes["..."] = true + markup.scan(/(\.\.\.)(\w+)?(?=\s|,|$)|#{TagAttributes.source}/) do |spread, identifier, key, value| + if spread + if identifier + spread_key = "...#{identifier}" + @attributes.delete(spread_key) + @attributes[spread_key] = parse_expression(identifier) + else + @attributes.delete("...") + @attributes["..."] = true + end elsif key && value @attributes.delete(key) @attributes[key] = parse_expression(value) diff --git a/test/integration/tags/snippet_test.rb b/test/integration/tags/snippet_test.rb index 4906437d..8830285a 100644 --- a/test/integration/tags/snippet_test.rb +++ b/test/integration/tags/snippet_test.rb @@ -446,6 +446,154 @@ class SnippetTest < Minitest::Test assert_template_result(expected, template) end + def test_render_inline_snippet_with_spread_hash + template = <<~LIQUID.strip + {% snippet header %} +
+ {{ word }} {{ number }} +
+ {% endsnippet %} + + {% render header, ...details %} + LIQUID + + expected = <<~OUTPUT + + + +
+ potato 5 +
+ OUTPUT + + assert_template_result(expected, template, { 'details' => { 'word' => 'potato', 'number' => 5 } }) + end + + def test_render_inline_snippet_with_spread_drop + product_drop = Class.new(Liquid::Drop) do + def title + 'Cool Product' + end + + def price + 99 + end + + def vendor + 'Acme' + end + end + + template = <<~LIQUID.strip + {% snippet card %} +
+ {{ title }} - ${{ price }} by {{ vendor }} +
+ {% endsnippet %} + + {% render card, ...product %} + LIQUID + + expected = <<~OUTPUT + + + +
+ Cool Product - $99 by Acme +
+ OUTPUT + + assert_template_result(expected, template, { 'product' => product_drop.new }) + end + + def test_render_inline_snippet_with_overwritten_spread_drop + product_drop = Class.new(Liquid::Drop) do + def title + 'Cool Product' + end + + def price + 99 + end + + def vendor + 'Acme' + end + end + + template = <<~LIQUID.strip + {% snippet card %} +
+ {{ title }} - ${{ price }} by {{ vendor }} +
+ {% endsnippet %} + + {% render card, ...product, price: 10 %} + LIQUID + + expected = <<~OUTPUT + + + +
+ Cool Product - $10 by Acme +
+ OUTPUT + + assert_template_result(expected, template, { 'product' => product_drop.new }) + end + + def test_render_inline_snippet_spread_before_explicit_args + template = <<~LIQUID.strip + {% snippet card %} +
{{ price }}
+ {% endsnippet %} + + {% render card, ...details, price: 10 %} + LIQUID + + expected = <<~OUTPUT + + + +
10
+ OUTPUT + + assert_template_result(expected, template, { 'details' => { 'price' => 99 } }) + end + + def test_render_inline_snippet_multiple_spreads + product_drop = Class.new(Liquid::Drop) do + def title + 'Cool Product' + end + end + + template = <<~LIQUID.strip + {% snippet card %} +
{{ title }} - {{ price }} {{ color }}
+ {% endsnippet %} + + {% render card, ...defaults, ...product %} + LIQUID + + expected = <<~OUTPUT + + + +
Cool Product - 10
+ OUTPUT + + assert_template_result( + expected, + template, + { + 'defaults' => { 'title' => 'Default', 'price' => 10 }, + 'product' => product_drop.new, + }, + ) + end + def test_render_captured_snippet template = <<~LIQUID {% assign color_scheme = 'dark' %} @@ -1063,6 +1211,132 @@ class SnippetTest < Minitest::Test assert_template_result(expected, template, error_mode: :rigid) end + def test_render_inline_snippet_with_spread_drop + product_drop = Class.new(Liquid::Drop) do + def title + 'Cool Product' + end + + def price + 99 + end + + def vendor + 'Acme' + end + end + + template = <<~LIQUID.strip + {% snippet card %} +
+ {{ title }} - ${{ price }} by {{ vendor }} +
+ {% endsnippet %} + + {% render card, ...product %} + LIQUID + + expected = <<~OUTPUT + + + +
+ Cool Product - $99 by Acme +
+ OUTPUT + + assert_template_result(expected, template, { 'product' => product_drop.new }, error_mode: :rigid) + end + + def test_render_inline_snippet_with_overwritten_spread_drop + product_drop = Class.new(Liquid::Drop) do + def title + 'Cool Product' + end + + def price + 99 + end + + def vendor + 'Acme' + end + end + + template = <<~LIQUID.strip + {% snippet card %} +
+ {{ title }} - ${{ price }} by {{ vendor }} +
+ {% endsnippet %} + + {% render card, ...product, price: 10 %} + LIQUID + + expected = <<~OUTPUT + + + +
+ Cool Product - $10 by Acme +
+ OUTPUT + + assert_template_result(expected, template, { 'product' => product_drop.new }, error_mode: :rigid) + end + + def test_render_inline_snippet_spread_before_explicit_args + template = <<~LIQUID.strip + {% snippet card %} +
{{ price }}
+ {% endsnippet %} + + {% render card, ...details, price: 10 %} + LIQUID + + expected = <<~OUTPUT + + + +
10
+ OUTPUT + + assert_template_result(expected, template, { 'details' => { 'price' => 99 } }, error_mode: :rigid) + end + + def test_render_inline_snippet_multiple_spreads + product_drop = Class.new(Liquid::Drop) do + def title + 'Cool Product' + end + end + + template = <<~LIQUID.strip + {% snippet card %} +
{{ title }} - {{ price }} {{ color }}
+ {% endsnippet %} + + {% render card, ...defaults, ...product %} + LIQUID + + expected = <<~OUTPUT + + + +
Cool Product - 10
+ OUTPUT + + assert_template_result( + expected, + template, + { + 'defaults' => { 'title' => 'Default', 'price' => 10 }, + 'product' => product_drop.new, + }, + error_mode: :rigid, + ) + end + def test_render_captured_snippet template = <<~LIQUID {% assign color_scheme = 'dark' %}