From 8c6149782defd3a59c5db153ea5840531e19d8a0 Mon Sep 17 00:00:00 2001 From: Josh Faigan Date: Thu, 19 Sep 2024 12:03:43 -0400 Subject: [PATCH 01/19] Introduce new inline snippets tag Inline snippets will reduce code duplication and improve the developer experience, eliminating the need for one-off snippet files --- Gemfile | 4 + Gemfile.lock | 77 +++++++++++ example/server/parser_attempt.rb | 59 ++++++++ example/server/templates/index.liquid | 71 +++++++++- lib/liquid/locales/en.yml | 1 + lib/liquid/tags.rb | 2 + lib/liquid/tags/render.rb | 18 +++ lib/liquid/tags/snippet.rb | 51 +++++++ test/integration/tags/snippet_test.rb | 190 ++++++++++++++++++++++++++ 9 files changed, 470 insertions(+), 3 deletions(-) create mode 100644 Gemfile.lock create mode 100644 example/server/parser_attempt.rb create mode 100644 lib/liquid/tags/snippet.rb create mode 100644 test/integration/tags/snippet_test.rb diff --git a/Gemfile b/Gemfile index a404c0d4b..fc327c921 100644 --- a/Gemfile +++ b/Gemfile @@ -29,3 +29,7 @@ group :test do gem 'rubocop-shopify', '~> 2.12.0', require: false gem 'rubocop-performance', require: false end + +group :development do + gem "webrick" +end diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 000000000..eff33ff57 --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,77 @@ +GIT + remote: https://github.com/Shopify/liquid-c.git + revision: 5a786af7284df55e013ea20551c4b688d02e8326 + ref: main + specs: + liquid-c (4.2.0) + liquid (>= 5.0.1) + +PATH + remote: . + specs: + liquid (5.6.0.alpha) + +GEM + remote: https://rubygems.org/ + specs: + ast (2.4.2) + base64 (0.2.0) + benchmark-ips (2.13.0) + json (2.7.2) + language_server-protocol (3.17.0.3) + memory_profiler (1.0.1) + minitest (5.22.3) + parallel (1.24.0) + parser (3.3.0.5) + ast (~> 2.4.1) + racc + racc (1.7.3) + rainbow (3.1.1) + rake (13.2.1) + regexp_parser (2.9.0) + rexml (3.2.6) + rubocop (1.61.0) + json (~> 2.3) + language_server-protocol (>= 3.17.0) + parallel (~> 1.10) + parser (>= 3.3.0.2) + rainbow (>= 2.2.2, < 4.0) + regexp_parser (>= 1.8, < 3.0) + rexml (>= 3.2.5, < 4.0) + rubocop-ast (>= 1.30.0, < 2.0) + ruby-progressbar (~> 1.7) + unicode-display_width (>= 2.4.0, < 3.0) + rubocop-ast (1.31.2) + parser (>= 3.3.0.4) + rubocop-performance (1.19.1) + rubocop (>= 1.7.0, < 2.0) + rubocop-ast (>= 0.4.0) + rubocop-shopify (2.12.0) + rubocop (~> 1.44) + ruby-progressbar (1.13.0) + stackprof (0.2.26) + terminal-table (3.0.2) + unicode-display_width (>= 1.1.1, < 3) + unicode-display_width (2.5.0) + webrick (1.8.1) + +PLATFORMS + ruby + +DEPENDENCIES + base64 + benchmark-ips + liquid! + liquid-c! + memory_profiler + minitest + rake (~> 13.0) + rubocop (~> 1.61.0) + rubocop-performance + rubocop-shopify (~> 2.12.0) + stackprof + terminal-table + webrick + +BUNDLED WITH + 2.5.7 diff --git a/example/server/parser_attempt.rb b/example/server/parser_attempt.rb new file mode 100644 index 000000000..68e214dab --- /dev/null +++ b/example/server/parser_attempt.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +require 'bundler/inline' + +gemfile(true) do + source "https://rubygems.org" + gem 'liquid' +end + +require 'liquid' + +class Parser + def initialize(template) + @template = template + end + + def parse + @parsed_template = Liquid::Template.parse(@template) + end + + def test_parse + document = @parsed_template.root + + variables = [] + + if document.is_a?(Liquid::Document) + body = document.body + + if body.is_a?(Liquid::BlockBody) + body.nodelist.each do |node| + next unless node.is_a?(Liquid::Variable) + + puts node.inspect + variable_name = node.name.name + variables << variable_name + end + end + end + puts "Variables: #{variables}" + end + + def render + @parsed_template.render + end +end + +starter_template = "{{ foo }}" +starter_template_2 = "{{foo}}, {{bar}}" +starter_template_2_1 = "{{ foo }} and {{ bar }}" +starter_template_3 = "{% assign foo = 'bar' %}{{ foo }}" +# Let's start small here +template = <<~LIQUID + {% assign foo = 'bar' %} + {{ foo }} +LIQUID + +parser = Parser.new(starter_template) +parser.parse +parser.test_parse diff --git a/example/server/templates/index.liquid b/example/server/templates/index.liquid index 4872aa845..529055089 100644 --- a/example/server/templates/index.liquid +++ b/example/server/templates/index.liquid @@ -1,6 +1,71 @@ -

Hello world!

+ + -

It is {{date}}

+ + + + Simple Code Editor + + + + + + + + +
+ {% snippet "main" %} -

Check out the Products screen

+ {% # Snippet input %} + {% snippet "input" |type, name| %} +
+ + +
+ {% endsnippet %} + + {% snippet "league" %} +

Welcome to the league of super evil

+ {% endsnippet %} + + {% render "league" %} + {% render "input", type: "text" %} + {% render "input", type: "password" %} + + {% endsnippet %} + {% render 'main' %} +
+ + + + + + diff --git a/lib/liquid/locales/en.yml b/lib/liquid/locales/en.yml index b99d490c8..b2196686f 100644 --- a/lib/liquid/locales/en.yml +++ b/lib/liquid/locales/en.yml @@ -5,6 +5,7 @@ block_tag_unexpected_args: "Syntax Error in '%{tag}' - Valid syntax: {% %{tag} %}{% end%{tag} %}" assign: "Syntax Error in 'assign' - Valid syntax: assign [var] = [source]" capture: "Syntax Error in 'capture' - Valid syntax: capture [var]" + snippet: "Syntax Error in 'snippet' - Valid syntax: snippet [quoted string]" case: "Syntax Error in 'case' - Valid syntax: case [condition]" case_invalid_when: "Syntax Error in tag 'case' - Valid when condition: {% when [condition] [or condition2...] %}" case_invalid_else: "Syntax Error in tag 'case' - Valid else condition: {% else %} (no parameters) " diff --git a/lib/liquid/tags.rb b/lib/liquid/tags.rb index dff7553f7..3ec5e7cfb 100644 --- a/lib/liquid/tags.rb +++ b/lib/liquid/tags.rb @@ -20,6 +20,7 @@ require_relative "tags/render" require_relative "tags/cycle" require_relative "tags/doc" +require_relative "tags/snippet" module Liquid module Tags @@ -44,6 +45,7 @@ module Tags 'echo' => Echo, 'tablerow' => TableRow, 'doc' => Doc, + 'snippet' => Snippet, }.freeze end end diff --git a/lib/liquid/tags/render.rb b/lib/liquid/tags/render.rb index 4d29e420e..5bff60737 100644 --- a/lib/liquid/tags/render.rb +++ b/lib/liquid/tags/render.rb @@ -51,6 +51,24 @@ def render_tag(context, output) template_name = @template_name_expr raise ::ArgumentError unless template_name.is_a?(String) + # Inline snippets take precedence over external snippets + if (inline_snippet = context.registers[:inline_snippet][template_name]) + inner_context = context.new_isolated_subcontext + + snippet_body = inline_snippet[:body] + snippet_args = inline_snippet[:args] + # Validate and set the arguments in the inner context + @attributes.each do |key, value| + unless snippet_args.include?(key) + raise Liquid::ArgumentError, "Invalid argument `#{key}` for snippet `#{template_name}`" + end + + inner_context[key] = context.evaluate(value) + end + + return output << snippet_body.render(inner_context) + end + partial = PartialCache.load( template_name, context: context, diff --git a/lib/liquid/tags/snippet.rb b/lib/liquid/tags/snippet.rb new file mode 100644 index 000000000..72c6c2822 --- /dev/null +++ b/lib/liquid/tags/snippet.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +module Liquid + # @liquid_public_docs + # @liquid_type tag + # @liquid_category theme + # @liquid_name snippet + # @liquid_summary + # Creates a new inline snippet using a string value as the identifier. + # @liquid_description + # You can create inline snippets to make your Liquid code more modular. + # @liquid_syntax + # {% snippet "input" %} + # value + # {% endsnippet %} + class Snippet < Block + SYNTAX = /(#{QuotedString})(?:\s*\|\s*([\w\s,]+)\s*\|)?/o + def initialize(tag_name, markup, options) + super + + if markup =~ SYNTAX + @to = Regexp.last_match(1) + args = Regexp.last_match(2) + + @args = args ? args.split(/\s*,\s*/) : [] + else + raise SyntaxError, options[:locale].t("errors.syntax.snippet") + end + end + + def render(context) + context.registers[:inline_snippet] ||= {} + context.registers[:inline_snippet][snippet_id] = { + body: snippet_body, + args: @args, + } + '' + end + + private + + def snippet_id + @to[1, @to.size - 2] + end + + def snippet_body + body = @body + body + end + end +end diff --git a/test/integration/tags/snippet_test.rb b/test/integration/tags/snippet_test.rb new file mode 100644 index 000000000..fd1cbcdbe --- /dev/null +++ b/test/integration/tags/snippet_test.rb @@ -0,0 +1,190 @@ +# frozen_string_literal: true + +require 'test_helper' + +class SnippetTest < Minitest::Test + include Liquid + + def test_valid_inline_snippet + template = <<~LIQUID.strip + {% snippet "input" %} + Hey + {% endsnippet %} + LIQUID + expected = '' + + assert_template_result(expected, template) + end + + def test_invalid_inline_snippet + template = <<~LIQUID.strip + {% snippet input %} + Hey + {% endsnippet %} + LIQUID + expected = "Syntax Error in 'snippet' - Valid syntax: snippet [quoted string]" + + assert_match_syntax_error(expected, template) + end + + def test_render_inline_snippet + template = <<~LIQUID.strip + {% snippet "hey" %} + Hey + {% endsnippet %} + + {%- render "hey" -%} + LIQUID + expected = <<~OUTPUT + + Hey + OUTPUT + + assert_template_result(expected, template) + end + + def test_render_multiple_inline_snippets + template = <<~LIQUID.strip + {% snippet "input" %} + + {% endsnippet %} + + {% snippet "banner" %} + + Welcome to my store! + + {% endsnippet %} + + {%- render "input" -%} + {%- render "banner" -%} + LIQUID + expected = <<~OUTPUT + + + + + + + Welcome to my store! + + OUTPUT + + assert_template_result(expected, template) + end + + def test_render_inline_snippet_with_argument + template = <<~LIQUID.strip + {% snippet "input" |type| %} + + {% endsnippet %} + + {%- render "input", type: "text" -%} + LIQUID + expected = <<~OUTPUT + + + OUTPUT + + assert_template_result(expected, template) + end + + def test_render_inline_snippet_with_multiple_arguments + template = <<~LIQUID.strip + {% snippet "input" |type, value| %} + + {% endsnippet %} + + {%- render "input", type: "text", value: "Hello" -%} + LIQUID + expected = <<~OUTPUT + + + OUTPUT + + assert_template_result(expected, template) + end + + def test_render_inline_snippets_using_same_argument_name + template = <<~LIQUID.strip + {% snippet "input" |type| %} + + {% endsnippet %} + + {% snippet "inputs" |type, value| %} + + {% endsnippet %} + + {%- render "input", type: "text" -%} + {%- render "inputs", type: "password", value: "pass" -%} + LIQUID + expected = <<~OUTPUT + + + + + + + OUTPUT + + assert_template_result(expected, template) + end + + def test_render_inline_snippet_empty_string_when_missing_argument + template = <<~LIQUID.strip + {% snippet "input" |type| %} + + {% endsnippet %} + + {%- render "input", type: "text" -%} + LIQUID + expected = <<~OUTPUT + + + OUTPUT + + assert_template_result(expected, template) + end + + def test_render_inline_snippet_shouldnt_leak_context + template = <<~LIQUID.strip + {% snippet "input" |type, value| %} + + {% endsnippet %} + + {%- render "input", type: "text", value: "Hello" -%} + + {{ type }} + {{ value }} + LIQUID + expected = <<~OUTPUT + + + + OUTPUT + + assert_template_result(expected, template) + end + + def test_render_multiple_inline_snippets_without_leaking_context + template = <<~LIQUID.strip + {% snippet "input" |type| %} + + {% endsnippet %} + {% snippet "no_leak" %} + + {% endsnippet %} + + {%- render "input", type: "text" -%} + {%- render "no_leak" -%} + LIQUID + expected = <<~OUTPUT + + + + + + OUTPUT + + assert_template_result(expected, template) + end +end From 4d13f030f83d43a29e5b28858a4e91625b799ed0 Mon Sep 17 00:00:00 2001 From: Julia Boutin Date: Fri, 26 Sep 2025 17:46:44 -0600 Subject: [PATCH 02/19] Update inline snippets syntax Previously, inline snippets syntax looked a bit different, they: - used strings as tag identifiers - defined tag arguments {% snippet "input" |type| %} This PR updates snippets to better reflect the currently proposed syntax Co-authored-by: Orlando Qiu --- lib/liquid/tags/render.rb | 12 +- lib/liquid/tags/snippet.rb | 29 +--- test/integration/tags/snippet_test.rb | 216 +++++++++++++++++++++++--- 3 files changed, 204 insertions(+), 53 deletions(-) diff --git a/lib/liquid/tags/render.rb b/lib/liquid/tags/render.rb index 5bff60737..c142caca5 100644 --- a/lib/liquid/tags/render.rb +++ b/lib/liquid/tags/render.rb @@ -54,15 +54,15 @@ def render_tag(context, output) # Inline snippets take precedence over external snippets if (inline_snippet = context.registers[:inline_snippet][template_name]) inner_context = context.new_isolated_subcontext - snippet_body = inline_snippet[:body] - snippet_args = inline_snippet[:args] - # Validate and set the arguments in the inner context - @attributes.each do |key, value| - unless snippet_args.include?(key) - raise Liquid::ArgumentError, "Invalid argument `#{key}` for snippet `#{template_name}`" + + context.scopes.each do |scope| + scope.each do |key, value| + inner_context[key] = value end + end + @attributes.each do |key, value| inner_context[key] = context.evaluate(value) end diff --git a/lib/liquid/tags/snippet.rb b/lib/liquid/tags/snippet.rb index 72c6c2822..7f9072742 100644 --- a/lib/liquid/tags/snippet.rb +++ b/lib/liquid/tags/snippet.rb @@ -1,28 +1,25 @@ # frozen_string_literal: true module Liquid - # @liquid_public_docs # @liquid_type tag # @liquid_category theme # @liquid_name snippet # @liquid_summary - # Creates a new inline snippet using a string value as the identifier. + # Creates a new inline snippet. # @liquid_description # You can create inline snippets to make your Liquid code more modular. # @liquid_syntax - # {% snippet "input" %} + # {% snippet input %} # value # {% endsnippet %} + class Snippet < Block - SYNTAX = /(#{QuotedString})(?:\s*\|\s*([\w\s,]+)\s*\|)?/o + SYNTAX = /(#{VariableSignature}+)/o + def initialize(tag_name, markup, options) super - if markup =~ SYNTAX @to = Regexp.last_match(1) - args = Regexp.last_match(2) - - @args = args ? args.split(/\s*,\s*/) : [] else raise SyntaxError, options[:locale].t("errors.syntax.snippet") end @@ -30,22 +27,10 @@ def initialize(tag_name, markup, options) def render(context) context.registers[:inline_snippet] ||= {} - context.registers[:inline_snippet][snippet_id] = { - body: snippet_body, - args: @args, + context.registers[:inline_snippet][@to] = { + body: @body, } '' end - - private - - def snippet_id - @to[1, @to.size - 2] - end - - def snippet_body - body = @body - body - end end end diff --git a/test/integration/tags/snippet_test.rb b/test/integration/tags/snippet_test.rb index fd1cbcdbe..245862278 100644 --- a/test/integration/tags/snippet_test.rb +++ b/test/integration/tags/snippet_test.rb @@ -7,7 +7,7 @@ class SnippetTest < Minitest::Test def test_valid_inline_snippet template = <<~LIQUID.strip - {% snippet "input" %} + {% snippet input %} Hey {% endsnippet %} LIQUID @@ -16,20 +16,9 @@ def test_valid_inline_snippet assert_template_result(expected, template) end - def test_invalid_inline_snippet - template = <<~LIQUID.strip - {% snippet input %} - Hey - {% endsnippet %} - LIQUID - expected = "Syntax Error in 'snippet' - Valid syntax: snippet [quoted string]" - - assert_match_syntax_error(expected, template) - end - def test_render_inline_snippet template = <<~LIQUID.strip - {% snippet "hey" %} + {% snippet hey %} Hey {% endsnippet %} @@ -45,11 +34,11 @@ def test_render_inline_snippet def test_render_multiple_inline_snippets template = <<~LIQUID.strip - {% snippet "input" %} + {% snippet input %} {% endsnippet %} - {% snippet "banner" %} + {% snippet banner %} Welcome to my store! @@ -74,7 +63,27 @@ def test_render_multiple_inline_snippets def test_render_inline_snippet_with_argument template = <<~LIQUID.strip - {% snippet "input" |type| %} + {% snippet input %} + + {% endsnippet %} + + {%- render "input", type: "text" -%} + LIQUID + expected = <<~OUTPUT + + + OUTPUT + + assert_template_result(expected, template) + end + + def test_render_inline_snippet_with_doc_tag + template = <<~LIQUID.strip + {% snippet input %} + {% doc %} + @param {string} type - Input type. + {% enddoc %} + {% endsnippet %} @@ -82,6 +91,8 @@ def test_render_inline_snippet_with_argument LIQUID expected = <<~OUTPUT + + OUTPUT @@ -90,7 +101,12 @@ def test_render_inline_snippet_with_argument def test_render_inline_snippet_with_multiple_arguments template = <<~LIQUID.strip - {% snippet "input" |type, value| %} + {% snippet input %} + {% doc %} + @param {string} type - Input type. + @param {string} value - Input value. + {% enddoc %} + {% endsnippet %} @@ -98,6 +114,8 @@ def test_render_inline_snippet_with_multiple_arguments LIQUID expected = <<~OUTPUT + + OUTPUT @@ -106,24 +124,25 @@ def test_render_inline_snippet_with_multiple_arguments def test_render_inline_snippets_using_same_argument_name template = <<~LIQUID.strip - {% snippet "input" |type| %} + {% snippet input %} {% endsnippet %} - {% snippet "inputs" |type, value| %} - + {% snippet inputs %} + {% endsnippet %} {%- render "input", type: "text" -%} {%- render "inputs", type: "password", value: "pass" -%} LIQUID + expected = <<~OUTPUT - + OUTPUT assert_template_result(expected, template) @@ -131,7 +150,12 @@ def test_render_inline_snippets_using_same_argument_name def test_render_inline_snippet_empty_string_when_missing_argument template = <<~LIQUID.strip - {% snippet "input" |type| %} + {% snippet input %} + {% doc %} + @param {string} type - Input type. + @param {string} value - Input value. + {% enddoc %} + {% endsnippet %} @@ -139,6 +163,8 @@ def test_render_inline_snippet_empty_string_when_missing_argument LIQUID expected = <<~OUTPUT + + OUTPUT @@ -147,7 +173,12 @@ def test_render_inline_snippet_empty_string_when_missing_argument def test_render_inline_snippet_shouldnt_leak_context template = <<~LIQUID.strip - {% snippet "input" |type, value| %} + {% snippet input %} + {% doc %} + @param {string} type - Input type. + @param {string} value - Input value. + {% enddoc %} + {% endsnippet %} @@ -158,6 +189,8 @@ def test_render_inline_snippet_shouldnt_leak_context LIQUID expected = <<~OUTPUT + + OUTPUT @@ -167,10 +200,15 @@ def test_render_inline_snippet_shouldnt_leak_context def test_render_multiple_inline_snippets_without_leaking_context template = <<~LIQUID.strip - {% snippet "input" |type| %} + {% snippet input %} + {% doc %} + @param {string} type - Input type. + {% enddoc %} + {% endsnippet %} - {% snippet "no_leak" %} + + {% snippet no_leak %} {% endsnippet %} @@ -180,6 +218,9 @@ def test_render_multiple_inline_snippets_without_leaking_context expected = <<~OUTPUT + + + @@ -187,4 +228,129 @@ def test_render_multiple_inline_snippets_without_leaking_context assert_template_result(expected, template) end + + def test_render_parent_context_variable + template = <<~LIQUID.strip + {% assign color_scheme = 'dark' %} + + {% snippet header %} + {% doc %} + @param {string} message - Message. + {% enddoc %} + +
+ {{ message }} +
+ {% endsnippet %} + + {%- render "header", message: "Welcome to my site" -%} + LIQUID + expected = <<~OUTPUT + + + + + +
+ Welcome to my site +
+ OUTPUT + + assert_template_result(expected, template) + end + + def test_deeply_nested_snippets + template = <<~LIQUID.strip + {% assign color_scheme = 'first-color' %} + {% snippet first %} + {% assign color_scheme = 'second-color' %} + {% snippet second %} + {% assign color_scheme = 'third-color' %} + {% snippet third %} +
+ This is a header +
+ {% endsnippet %} + {%- render "third" -%} + {% endsnippet %} + {%- render "second" -%} + {% endsnippet %} + + {%- render "first" -%} + LIQUID + expected = <<~OUTPUT + + + + + + +
+ This is a header +
+ OUTPUT + + assert_template_result(expected, template) + end + + def test_render_snippet_with_variables_in_both_scopes + template = <<~LIQUID.strip + {% assign color_scheme = 'dark' %} + + {% snippet header %} + {% assign color_scheme = 'light' %} +
+ {{ message }} +
+ {% endsnippet %} + + {{ color_scheme }} + + {%- render "header", message: 'Welcome to my site' -%} + LIQUID + expected = <<~OUTPUT + + + + + dark + +
+ Welcome to my site +
+ OUTPUT + + assert_template_result(expected, template) + end + + # def test_render_snippets_as_arguments + # template = <<~LIQUID.strip + # {% assign color_scheme = 'dark' %} + + # {% snippet main_header %} + # {% assign color_scheme = 'auto' %} + + #
+ # {%- render "header", message: 'Welcome to my site' -%} + #
+ # {% endsnippet %} + + # {% snippet header %} + #
+ # {{ message }} + #
+ # {% endsnippet %} + + # {%- render "main_header", header: header -%} + # LIQUID + # expected = <<~OUTPUT + #
+ #
+ # Welcome to my site + #
+ #
+ # OUTPUT + + # assert_template_result(expected, template) + # end end From 3a13ac7e6c47aad98b2cacc47784ab51a643ff0e Mon Sep 17 00:00:00 2001 From: Julia Boutin Date: Tue, 30 Sep 2025 16:29:40 -0600 Subject: [PATCH 03/19] Create SnippetDrop and set in scope --- lib/liquid.rb | 1 + lib/liquid/snippet_drop.rb | 16 +++ lib/liquid/tags/render.rb | 19 +-- lib/liquid/tags/snippet.rb | 14 ++- test/integration/tags/snippet_test.rb | 167 +++++++++++++++++++------- 5 files changed, 161 insertions(+), 56 deletions(-) create mode 100644 lib/liquid/snippet_drop.rb diff --git a/lib/liquid.rb b/lib/liquid.rb index 4d0a71a64..09cafe947 100644 --- a/lib/liquid.rb +++ b/lib/liquid.rb @@ -67,6 +67,7 @@ module Liquid require 'liquid/drop' require 'liquid/tablerowloop_drop' require 'liquid/forloop_drop' +require 'liquid/snippet_drop' require 'liquid/extensions' require 'liquid/errors' require 'liquid/interrupts' diff --git a/lib/liquid/snippet_drop.rb b/lib/liquid/snippet_drop.rb new file mode 100644 index 000000000..fb48d068d --- /dev/null +++ b/lib/liquid/snippet_drop.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Liquid + class SnippetDrop < Drop + attr_reader :body + + def initialize(body) + super() + @body = body + end + + def to_s + 'SnippetDrop' + end + end +end diff --git a/lib/liquid/tags/render.rb b/lib/liquid/tags/render.rb index c142caca5..03f231eaa 100644 --- a/lib/liquid/tags/render.rb +++ b/lib/liquid/tags/render.rb @@ -42,6 +42,10 @@ def for_loop? @is_for_loop end + def inherit_context? + @inherit_context + end + def render_to_output_buffer(context, output) render_tag(context, output) end @@ -51,14 +55,15 @@ def render_tag(context, output) template_name = @template_name_expr raise ::ArgumentError unless template_name.is_a?(String) - # Inline snippets take precedence over external snippets - if (inline_snippet = context.registers[:inline_snippet][template_name]) + if context[template_name].is_a?(Liquid::SnippetDrop) + snippet_drop = context[template_name] inner_context = context.new_isolated_subcontext - snippet_body = inline_snippet[:body] - context.scopes.each do |scope| - scope.each do |key, value| - inner_context[key] = value + if inherit_context? + context.scopes.each do |scope| + scope.each do |key, value| + inner_context[key] = value + end end end @@ -66,7 +71,7 @@ def render_tag(context, output) inner_context[key] = context.evaluate(value) end - return output << snippet_body.render(inner_context) + return output << snippet_drop.body.render(inner_context) end partial = PartialCache.load( diff --git a/lib/liquid/tags/snippet.rb b/lib/liquid/tags/snippet.rb index 7f9072742..e1d978b61 100644 --- a/lib/liquid/tags/snippet.rb +++ b/lib/liquid/tags/snippet.rb @@ -25,12 +25,14 @@ def initialize(tag_name, markup, options) end end - def render(context) - context.registers[:inline_snippet] ||= {} - context.registers[:inline_snippet][@to] = { - body: @body, - } - '' + def render_to_output_buffer(context, output) + snippet_drop = SnippetDrop.new(@body) + context.scopes.last[@to] = snippet_drop + output + end + + def blank? + true end end end diff --git a/test/integration/tags/snippet_test.rb b/test/integration/tags/snippet_test.rb index 245862278..27e50ec68 100644 --- a/test/integration/tags/snippet_test.rb +++ b/test/integration/tags/snippet_test.rb @@ -229,21 +229,18 @@ def test_render_multiple_inline_snippets_without_leaking_context assert_template_result(expected, template) end - def test_render_parent_context_variable + def test_render_inline_snippet_without_outside_context template = <<~LIQUID.strip {% assign color_scheme = 'dark' %} {% snippet header %} - {% doc %} - @param {string} message - Message. - {% enddoc %} -
{{ message }}
{% endsnippet %} - {%- render "header", message: "Welcome to my site" -%} + + {% render "header", message: 'Welcome!' %} LIQUID expected = <<~OUTPUT @@ -251,32 +248,27 @@ def test_render_parent_context_variable -
- Welcome to my site + +
+ Welcome!
OUTPUT assert_template_result(expected, template) end - def test_deeply_nested_snippets + def test_render_inline_snippet_with_outside_context template = <<~LIQUID.strip - {% assign color_scheme = 'first-color' %} - {% snippet first %} - {% assign color_scheme = 'second-color' %} - {% snippet second %} - {% assign color_scheme = 'third-color' %} - {% snippet third %} + {% assign color_scheme = 'dark' %} + + {% snippet header %}
- This is a header + {{ message }}
{% endsnippet %} - {%- render "third" -%} - {% endsnippet %} - {%- render "second" -%} - {% endsnippet %} - {%- render "first" -%} + + {% render "header", ..., message: 'Welcome!' %} LIQUID expected = <<~OUTPUT @@ -285,15 +277,15 @@ def test_deeply_nested_snippets -
- This is a header +
+ Welcome!
OUTPUT assert_template_result(expected, template) end - def test_render_snippet_with_variables_in_both_scopes + def test_inline_snippet_local_scope_takes_precedence template = <<~LIQUID.strip {% assign color_scheme = 'dark' %} @@ -306,7 +298,9 @@ def test_render_snippet_with_variables_in_both_scopes {{ color_scheme }} - {%- render "header", message: 'Welcome to my site' -%} + {% render "header", ..., message: 'Welcome!' %} + + {{ color_scheme }} LIQUID expected = <<~OUTPUT @@ -315,39 +309,126 @@ def test_render_snippet_with_variables_in_both_scopes dark + +
- Welcome to my site + Welcome!
+ + + dark OUTPUT assert_template_result(expected, template) end - # def test_render_snippets_as_arguments - # template = <<~LIQUID.strip - # {% assign color_scheme = 'dark' %} + def test_render_captured_snippet + template = <<~LIQUID.strip + {% assign color_scheme = 'dark' %} - # {% snippet main_header %} - # {% assign color_scheme = 'auto' %} + {% snippet header %} +
+ {{ message }} +
+ {% endsnippet %} + + {% capture up_header %} + {% render "header", ..., message: 'Welcome!' %} + {% endcapture %} + + {{ up_header | upcase }} + + {{ header | upcase }} + + {{ header }} + LIQUID + expected = <<~OUTPUT + + + + + + + + + +
+ WELCOME! +
- #
- # {%- render "header", message: 'Welcome to my site' -%} - #
- # {% endsnippet %} + + SNIPPETDROP + + SnippetDrop + OUTPUT + + assert_template_result(expected, template) + end + + def test_render_snippets_as_arguments + template = <<~LIQUID.strip + {% assign color_scheme = 'dark' %} + + {% snippet header %} +
+ {{ message }} +
+ {% endsnippet %} + + {% snippet main %} + {% assign color_scheme = 'auto' %} + +
+ {% render "header", ..., message: 'Welcome!' %} +
+ {% endsnippet %} + + {% render "main", header: header %} + LIQUID + + expected = <<~OUTPUT + + + + + + + + + + +
+ +
+ Welcome! +
+ +
+ OUTPUT + + assert_template_result(expected, template) + end + + # def test_render_inline_snippet_inside_loop + # template = <<~LIQUID.strip + # {% assign color_scheme = 'dark' %} + # {% assign array = '1,2,3' | split: ',' %} + + # {% for i in array %} # {% snippet header %} - #
- # {{ message }} - #
+ #
+ # {{ message }} {{ i }} + #
# {% endsnippet %} + # {% endfor %} - # {%- render "main_header", header: header -%} + # {% render "header", ..., message: '👉' %} # LIQUID # expected = <<~OUTPUT - #
- #
- # Welcome to my site - #
+ + #
+ # 👉 3 #
# OUTPUT From db474d5b57e631e0f2fbfbc120dd582eed26af1e Mon Sep 17 00:00:00 2001 From: Julia Boutin Date: Thu, 2 Oct 2025 19:26:17 -0600 Subject: [PATCH 04/19] Change inline snippet identifier from string to variable Currently, snippet files identified by strings. This PR makes changes to render to allow for new inline snippets to use variables as identifiers instead --- example/server/templates/index.liquid | 87 ++++++++------------------- lib/liquid/tags/render.rb | 16 +++-- test/integration/tags/snippet_test.rb | 56 ++++++++++------- 3 files changed, 70 insertions(+), 89 deletions(-) diff --git a/example/server/templates/index.liquid b/example/server/templates/index.liquid index 529055089..d4ea6afcf 100644 --- a/example/server/templates/index.liquid +++ b/example/server/templates/index.liquid @@ -1,71 +1,32 @@ - - - + + - - - Simple Code Editor - - - - - - - - - -
- {% snippet "main" %} - - {% # Snippet input %} - {% snippet "input" |type, name| %} -
- - -
- {% endsnippet %} + + + Inline Snippets - {% snippet "league" %} -

Welcome to the league of super evil

- {% endsnippet %} - - {% render "league" %} - {% render "input", type: "text" %} - {% render "input", type: "password" %} + +
+ + + {% snippet main %} + {% assign foo = false %} +

Hi {{ arg | upcase }}!!!

+ +

This is an inline snippet

+ +
    +
  • wow a link
  • +
  • 1 + 1 = {{ 1 | plus: 1 }}
  • +
  • {% if true %}Yes!{% endif %}
  • +
  • {% if foo %}NO{% endif %}
  • +
  • {{ missing_var | default: 'fallback' }}
  • +
{% endsnippet %} - {% render 'main' %} -
- + {% render main, arg: 'lsf' %} - +
diff --git a/lib/liquid/tags/render.rb b/lib/liquid/tags/render.rb index 03f231eaa..288e226d2 100644 --- a/lib/liquid/tags/render.rb +++ b/lib/liquid/tags/render.rb @@ -27,7 +27,7 @@ module Liquid # @liquid_syntax_keyword filename The name of the snippet to render, without the `.liquid` extension. class Render < Tag FOR = 'for' - SYNTAX = /(#{QuotedString}+)(\s+(with|#{FOR})\s+(#{QuotedFragment}+))?(\s+(?:as)\s+(#{VariableSegment}+))?/o + SYNTAX = /(#{QuotedString}+|#{VariableSegment}+)(\s+(with|#{FOR})\s+(#{QuotedFragment}+))?(\s+(?:as)\s+(#{VariableSegment}+))?/o disable_tags "include" @@ -51,12 +51,15 @@ def render_to_output_buffer(context, output) end def render_tag(context, output) - # The expression should be a String literal, which parses to a String object template_name = @template_name_expr - raise ::ArgumentError unless template_name.is_a?(String) - if context[template_name].is_a?(Liquid::SnippetDrop) - snippet_drop = context[template_name] + # For inline snippets, @template_name_expr is a VariableLookup + if template_name.is_a?(VariableLookup) + + snippet_drop = context[template_name.name] + + raise ::ArgumentError unless snippet_drop.is_a?(Liquid::SnippetDrop) + inner_context = context.new_isolated_subcontext if inherit_context? @@ -74,6 +77,9 @@ def render_tag(context, output) return output << snippet_drop.body.render(inner_context) end + # Otherwise, the expression should be a String literal, which parses to a String object + raise ::ArgumentError unless template_name.is_a?(String) + partial = PartialCache.load( template_name, context: context, diff --git a/test/integration/tags/snippet_test.rb b/test/integration/tags/snippet_test.rb index 27e50ec68..5dcd5465a 100644 --- a/test/integration/tags/snippet_test.rb +++ b/test/integration/tags/snippet_test.rb @@ -22,7 +22,7 @@ def test_render_inline_snippet Hey {% endsnippet %} - {%- render "hey" -%} + {%- render hey -%} LIQUID expected = <<~OUTPUT @@ -32,6 +32,22 @@ def test_render_inline_snippet assert_template_result(expected, template) end + def test_render_inline_snippet_with_variable + template = <<~LIQUID.strip + {% snippet hey %} +

Today is {{ "hello" | capitalize }}

+ {% endsnippet %} + + {%- render hey -%} + LIQUID + expected = <<~OUTPUT + +

Today is Hello

+ OUTPUT + + assert_template_result(expected, template) + end + def test_render_multiple_inline_snippets template = <<~LIQUID.strip {% snippet input %} @@ -44,8 +60,8 @@ def test_render_multiple_inline_snippets {% endsnippet %} - {%- render "input" -%} - {%- render "banner" -%} + {%- render input -%} + {%- render banner -%} LIQUID expected = <<~OUTPUT @@ -67,7 +83,7 @@ def test_render_inline_snippet_with_argument {% endsnippet %} - {%- render "input", type: "text" -%} + {%- render input, type: "text" -%} LIQUID expected = <<~OUTPUT @@ -87,7 +103,7 @@ def test_render_inline_snippet_with_doc_tag {% endsnippet %} - {%- render "input", type: "text" -%} + {%- render input, type: "text" -%} LIQUID expected = <<~OUTPUT @@ -110,7 +126,7 @@ def test_render_inline_snippet_with_multiple_arguments {% endsnippet %} - {%- render "input", type: "text", value: "Hello" -%} + {%- render input, type: "text", value: "Hello" -%} LIQUID expected = <<~OUTPUT @@ -132,8 +148,8 @@ def test_render_inline_snippets_using_same_argument_name {% endsnippet %} - {%- render "input", type: "text" -%} - {%- render "inputs", type: "password", value: "pass" -%} + {%- render input, type: "text" -%} + {%- render inputs, type: "password", value: "pass" -%} LIQUID expected = <<~OUTPUT @@ -159,7 +175,7 @@ def test_render_inline_snippet_empty_string_when_missing_argument {% endsnippet %} - {%- render "input", type: "text" -%} + {%- render input, type: "text" -%} LIQUID expected = <<~OUTPUT @@ -182,7 +198,7 @@ def test_render_inline_snippet_shouldnt_leak_context {% endsnippet %} - {%- render "input", type: "text", value: "Hello" -%} + {%- render input, type: "text", value: "Hello" -%} {{ type }} {{ value }} @@ -212,8 +228,8 @@ def test_render_multiple_inline_snippets_without_leaking_context {% endsnippet %} - {%- render "input", type: "text" -%} - {%- render "no_leak" -%} + {%- render input, type: "text" -%} + {%- render no_leak -%} LIQUID expected = <<~OUTPUT @@ -240,7 +256,7 @@ def test_render_inline_snippet_without_outside_context {% endsnippet %} - {% render "header", message: 'Welcome!' %} + {% render header, message: 'Welcome!' %} LIQUID expected = <<~OUTPUT @@ -268,7 +284,7 @@ def test_render_inline_snippet_with_outside_context {% endsnippet %} - {% render "header", ..., message: 'Welcome!' %} + {% render header, ..., message: 'Welcome!' %} LIQUID expected = <<~OUTPUT @@ -298,7 +314,7 @@ def test_inline_snippet_local_scope_takes_precedence {{ color_scheme }} - {% render "header", ..., message: 'Welcome!' %} + {% render header, ..., message: 'Welcome!' %} {{ color_scheme }} LIQUID @@ -333,7 +349,7 @@ def test_render_captured_snippet {% endsnippet %} {% capture up_header %} - {% render "header", ..., message: 'Welcome!' %} + {% render header, ..., message: 'Welcome!' %} {% endcapture %} {{ up_header | upcase }} @@ -351,7 +367,6 @@ def test_render_captured_snippet -
WELCOME!
@@ -380,11 +395,11 @@ def test_render_snippets_as_arguments {% assign color_scheme = 'auto' %}
- {% render "header", ..., message: 'Welcome!' %} + {% render header, ..., message: 'Welcome!' %}
{% endsnippet %} - {% render "main", header: header %} + {% render main, header: header %} LIQUID expected = <<~OUTPUT @@ -397,7 +412,6 @@ def test_render_snippets_as_arguments -
@@ -423,7 +437,7 @@ def test_render_snippets_as_arguments # {% endsnippet %} # {% endfor %} - # {% render "header", ..., message: '👉' %} + # {% render header, ..., message: '👉' %} # LIQUID # expected = <<~OUTPUT From a384e229d85304a034102a606cfb9760ff3ff46b Mon Sep 17 00:00:00 2001 From: Julia Boutin Date: Fri, 3 Oct 2025 07:32:34 -0600 Subject: [PATCH 05/19] Support ... inline snippet syntax --- example/server/templates/index.liquid | 10 ++- lib/liquid/lexer.rb | 10 ++- lib/liquid/tags/render.rb | 24 ++++- test/integration/tags/snippet_test.rb | 122 +++++++++++++++++++++++--- 4 files changed, 146 insertions(+), 20 deletions(-) diff --git a/example/server/templates/index.liquid b/example/server/templates/index.liquid index d4ea6afcf..0862f2168 100644 --- a/example/server/templates/index.liquid +++ b/example/server/templates/index.liquid @@ -8,7 +8,8 @@
- + {% assign foo = true %} + {% assign link = "variable" %} {% snippet main %} {% assign foo = false %} @@ -17,15 +18,16 @@

This is an inline snippet

    -
  • wow a link
  • +
  • wow a {{ link }}
  • 1 + 1 = {{ 1 | plus: 1 }}
  • {% if true %}Yes!{% endif %}
  • -
  • {% if foo %}NO{% endif %}
  • +
  • foo = {% if foo %}true{%else%}false{% endif %}
  • {{ missing_var | default: 'fallback' }}
{% endsnippet %} - {% render main, arg: 'lsf' %} + {% render main, arg: 'lsf', ... %} + {{ foo }}
diff --git a/lib/liquid/lexer.rb b/lib/liquid/lexer.rb index f1740dbad..94a3d9faf 100644 --- a/lib/liquid/lexer.rb +++ b/lib/liquid/lexer.rb @@ -17,6 +17,7 @@ class Lexer DASH = [:dash, "-"].freeze DOT = [:dot, "."].freeze DOTDOT = [:dotdot, ".."].freeze + DOTDOTDOT = [:dotdotdot, "..."].freeze DOT_ORD = ".".ord DOUBLE_STRING_LITERAL = /"[^\"]*"/ EOS = [:end_of_string].freeze @@ -113,10 +114,15 @@ def tokenize(ss) if (special = SPECIAL_TABLE[peeked]) ss.scan_byte - # Special case for ".." + # Special case for ".." and "..." if special == DOT && ss.peek_byte == DOT_ORD ss.scan_byte - output << DOTDOT + if ss.peek_byte == DOT_ORD + ss.scan_byte + output << DOTDOTDOT + else + output << DOTDOT + end elsif special == DASH # Special case for negative numbers if (peeked_byte = ss.peek_byte) && NUMBER_TABLE[peeked_byte] diff --git a/lib/liquid/tags/render.rb b/lib/liquid/tags/render.rb index 288e226d2..e84397b0f 100644 --- a/lib/liquid/tags/render.rb +++ b/lib/liquid/tags/render.rb @@ -62,7 +62,13 @@ def render_tag(context, output) inner_context = context.new_isolated_subcontext - if inherit_context? + if is_file + inner_context.template_name = partial.name + inner_context.partial = true + end + + if is_inline && inherit_context? + context.scopes.each do |scope| scope.each do |key, value| inner_context[key] = value @@ -125,6 +131,14 @@ def rigid_parse(markup) p.consume?(:comma) + @inherit_context = false + # ... inline snippets syntax + if p.consume?(:dotdotdot) + p.consume?(:comma) + + @inherit_context = true + end + @attributes = {} while p.look(:id) key = p.consume @@ -137,7 +151,12 @@ def rigid_parse(markup) end def rigid_template_name(p) - p.consume(:string) + if p.look(:string) + p.consume(:string) + # inline snippets use variable identifiers + elsif p.look(:id) + p.consume(:id) + end end def strict_parse(markup) @@ -155,6 +174,7 @@ def lax_parse(markup) @variable_name_expr = variable_name ? parse_expression(variable_name) : nil @template_name_expr = parse_expression(template_name) @is_for_loop = (with_or_for == FOR) + @inherit_context = markup.include?('...') @attributes = {} markup.scan(TagAttributes) do |key, value| diff --git a/test/integration/tags/snippet_test.rb b/test/integration/tags/snippet_test.rb index 5dcd5465a..9824a16e9 100644 --- a/test/integration/tags/snippet_test.rb +++ b/test/integration/tags/snippet_test.rb @@ -115,6 +115,23 @@ def test_render_inline_snippet_with_doc_tag assert_template_result(expected, template) end + def test_render_inline_snippet_with_evaluated_assign + template = <<~LIQUID.strip + {% snippet input %} +

{{ greeting }}

+ {% endsnippet %} + + {%- assign greeting = "hello" | upcase -%} + {%- render input, greeting: greeting -%} + LIQUID + expected = <<~OUTPUT + +

HELLO

+ OUTPUT + + assert_template_result(expected, template) + end + def test_render_inline_snippet_with_multiple_arguments template = <<~LIQUID.strip {% snippet input %} @@ -301,8 +318,36 @@ def test_render_inline_snippet_with_outside_context assert_template_result(expected, template) end + def test_render_inline_snippet_with_outside_context_rigid + template = <<~LIQUID.strip + {% assign color_scheme = 'dark' %} + + {% snippet header %} +
+ {{ message }} +
+ {% endsnippet %} + + + {% render header, ..., message: 'Welcome!' %} + LIQUID + expected = <<~OUTPUT + + + + + + +
+ Welcome! +
+ OUTPUT + + assert_template_result(expected, template, error_mode: :rigid) + end + def test_inline_snippet_local_scope_takes_precedence - template = <<~LIQUID.strip + template = <<~LIQUID {% assign color_scheme = 'dark' %} {% snippet header %} @@ -339,17 +384,17 @@ def test_inline_snippet_local_scope_takes_precedence end def test_render_captured_snippet - template = <<~LIQUID.strip + template = <<~LIQUID {% assign color_scheme = 'dark' %} {% snippet header %} -
- {{ message }} -
+
+ {{ message }} +
{% endsnippet %} {% capture up_header %} - {% render header, ..., message: 'Welcome!' %} + {%- render header, ..., message: 'Welcome!' -%} {% endcapture %} {{ up_header | upcase }} @@ -366,11 +411,9 @@ def test_render_captured_snippet - -
- WELCOME! -
- +
+ WELCOME! +
SNIPPETDROP @@ -395,7 +438,7 @@ def test_render_snippets_as_arguments {% assign color_scheme = 'auto' %}
- {% render header, ..., message: 'Welcome!' %} + {% render header, ..., message: 'Welcome!' %}
{% endsnippet %} @@ -448,4 +491,59 @@ def test_render_snippets_as_arguments # assert_template_result(expected, template) # end + + # def test_render_inline_snippet_forloop + # template = <<~LIQUID.strip + # {% snippet item %} + #
  • {{ forloop.index }}: {{ item }}
  • + # {% endsnippet %} + + # {% assign items = "A,B,C" | split: "," %} + # {%- render item for items -%} + # LIQUID + # expected = <<~OUTPUT + + #
  • 1: A
  • + + #
  • 2: B
  • + + #
  • 3: C
  • + # OUTPUT + + # assert_template_result(expected, template) + # end + + # def test_render_inline_snippet_with + # template = <<~LIQUID.strip + # {% snippet header %} + #
    {{ header }}
    + # {% endsnippet %} + + # {% assign product = "Apple" %} + # {%- render header with product -%} + # LIQUID + # expected = <<~OUTPUT + + #
    Apple
    + # OUTPUT + + # assert_template_result(expected, template) + # end + + # def test_render_inline_snippet_alias + # template = <<~LIQUID.strip + # {% snippet product_card %} + #
    {{ item }}
    + # {% endsnippet %} + + # {% assign featured = "Apple" %} + # {%- render product_card with featured as item -%} + # LIQUID + # expected = <<~OUTPUT + + #
    Apple
    + # OUTPUT + + # assert_template_result(expected, template) + # end end From ba5aa0abf65c5fcf87d4051a4fbe150564fbfc5b Mon Sep 17 00:00:00 2001 From: Julia Boutin Date: Fri, 3 Oct 2025 13:19:47 -0600 Subject: [PATCH 06/19] Support with, for, and as inline snippet syntax This commit updates the render method to share parts of the snippet and block rendering logic to enable inline snippets to support `with`, `for`, and `as` syntax --- Gemfile.lock | 17 +- example/server/templates/index.liquid | 17 +- lib/liquid/tags/render.rb | 44 ++--- lib/liquid/tags/snippet.rb | 5 +- test/integration/tags/render_tag_test.rb | 4 +- test/integration/tags/snippet_test.rb | 199 ++++++++++++----------- 6 files changed, 147 insertions(+), 139 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index eff33ff57..381c2b7e3 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,15 +1,9 @@ -GIT - remote: https://github.com/Shopify/liquid-c.git - revision: 5a786af7284df55e013ea20551c4b688d02e8326 - ref: main - specs: - liquid-c (4.2.0) - liquid (>= 5.0.1) - PATH remote: . specs: - liquid (5.6.0.alpha) + liquid (5.8.7) + bigdecimal + strscan (>= 3.1.1) GEM remote: https://rubygems.org/ @@ -17,8 +11,10 @@ GEM ast (2.4.2) base64 (0.2.0) benchmark-ips (2.13.0) + bigdecimal (3.2.3) json (2.7.2) language_server-protocol (3.17.0.3) + lru_redux (1.1.0) memory_profiler (1.0.1) minitest (5.22.3) parallel (1.24.0) @@ -50,6 +46,7 @@ GEM rubocop (~> 1.44) ruby-progressbar (1.13.0) stackprof (0.2.26) + strscan (3.1.5) terminal-table (3.0.2) unicode-display_width (>= 1.1.1, < 3) unicode-display_width (2.5.0) @@ -62,7 +59,7 @@ DEPENDENCIES base64 benchmark-ips liquid! - liquid-c! + lru_redux memory_profiler minitest rake (~> 13.0) diff --git a/example/server/templates/index.liquid b/example/server/templates/index.liquid index 0862f2168..a7a637dbf 100644 --- a/example/server/templates/index.liquid +++ b/example/server/templates/index.liquid @@ -5,12 +5,12 @@ Inline Snippets -
    + {% assign foo = true %} - {% assign link = "variable" %} - + {% assign linktext = "variable" %} + {% snippet main %} {% assign foo = false %}

    Hi {{ arg | upcase }}!!!

    @@ -18,7 +18,7 @@

    This is an inline snippet

      -
    • wow a {{ link }}
    • +
    • wow a {{ linktext }}
    • 1 + 1 = {{ 1 | plus: 1 }}
    • {% if true %}Yes!{% endif %}
    • foo = {% if foo %}true{%else%}false{% endif %}
    • @@ -29,6 +29,15 @@ {% render main, arg: 'lsf', ... %} {{ foo }} + {% snippet listitem %} +
    • {{ forloop.index }}: {{ emoji }}
    • + {% endsnippet %} + + {% assign emojis = "🌼,🌳,🌸" | split: "," %} +
        + {%- render listitem for emojis as emoji -%} +
      +
    diff --git a/lib/liquid/tags/render.rb b/lib/liquid/tags/render.rb index e84397b0f..db8fa768e 100644 --- a/lib/liquid/tags/render.rb +++ b/lib/liquid/tags/render.rb @@ -52,14 +52,24 @@ def render_to_output_buffer(context, output) def render_tag(context, output) template_name = @template_name_expr + is_inline = template_name.is_a?(VariableLookup) + is_file = template_name.is_a?(String) - # For inline snippets, @template_name_expr is a VariableLookup - if template_name.is_a?(VariableLookup) + if is_inline + template_name = template_name.name + snippet_drop = context[template_name] + raise ::ArgumentError unless snippet_drop.is_a?(Liquid::SnippetDrop) - snippet_drop = context[template_name.name] + partial = snippet_drop.body + else + raise ::ArgumentError unless is_file - raise ::ArgumentError unless snippet_drop.is_a?(Liquid::SnippetDrop) + partial = PartialCache.load(template_name, context: context, parse_context: parse_context) + end + + context_variable_name = @alias_name || template_name.split('/').last + render_partial_func = ->(var, forloop) { inner_context = context.new_isolated_subcontext if is_file @@ -68,7 +78,6 @@ def render_tag(context, output) end if is_inline && inherit_context? - context.scopes.each do |scope| scope.each do |key, value| inner_context[key] = value @@ -80,30 +89,9 @@ def render_tag(context, output) inner_context[key] = context.evaluate(value) end - return output << snippet_drop.body.render(inner_context) - end - - # Otherwise, the expression should be a String literal, which parses to a String object - raise ::ArgumentError unless template_name.is_a?(String) - - partial = PartialCache.load( - template_name, - context: context, - parse_context: parse_context, - ) - - context_variable_name = @alias_name || template_name.split('/').last - - render_partial_func = ->(var, forloop) { - inner_context = context.new_isolated_subcontext - inner_context.template_name = partial.name - inner_context.partial = true - inner_context['forloop'] = forloop if forloop - - @attributes.each do |key, value| - inner_context[key] = context.evaluate(value) - end inner_context[context_variable_name] = var unless var.nil? + inner_context['forloop'] = forloop if forloop + partial.render_to_output_buffer(inner_context, output) forloop&.send(:increment!) } diff --git a/lib/liquid/tags/snippet.rb b/lib/liquid/tags/snippet.rb index e1d978b61..aede69b78 100644 --- a/lib/liquid/tags/snippet.rb +++ b/lib/liquid/tags/snippet.rb @@ -18,8 +18,9 @@ class Snippet < Block def initialize(tag_name, markup, options) super - if markup =~ SYNTAX - @to = Regexp.last_match(1) + p = @parse_context.new_parser(markup) + if p.look(:id) + @to = p.consume(:id) else raise SyntaxError, options[:locale].t("errors.syntax.snippet") end diff --git a/test/integration/tags/render_tag_test.rb b/test/integration/tags/render_tag_test.rb index d6453fb51..e4ce0c294 100644 --- a/test/integration/tags/render_tag_test.rb +++ b/test/integration/tags/render_tag_test.rb @@ -102,7 +102,9 @@ def test_sub_contexts_count_towards_the_same_recursion_limit end def test_dynamically_choosen_templates_are_not_allowed - assert_syntax_error("{% assign name = 'snippet' %}{% render name %}") + assert_raises(::ArgumentError) do + Template.parse('{% assign name = "snippet" %}{% render name %}').render! + end end def test_rigid_parsing_errors diff --git a/test/integration/tags/snippet_test.rb b/test/integration/tags/snippet_test.rb index 9824a16e9..8699b359e 100644 --- a/test/integration/tags/snippet_test.rb +++ b/test/integration/tags/snippet_test.rb @@ -319,31 +319,31 @@ def test_render_inline_snippet_with_outside_context end def test_render_inline_snippet_with_outside_context_rigid - template = <<~LIQUID.strip - {% assign color_scheme = 'dark' %} + template = <<~LIQUID.strip + {% assign color_scheme = 'dark' %} - {% snippet header %} -
    - {{ message }} -
    - {% endsnippet %} + {% snippet header %} +
    + {{ message }} +
    + {% endsnippet %} - {% render header, ..., message: 'Welcome!' %} - LIQUID - expected = <<~OUTPUT + {% render header, ..., message: 'Welcome!' %} + LIQUID + expected = <<~OUTPUT -
    - Welcome! -
    - OUTPUT +
    + Welcome! +
    + OUTPUT - assert_template_result(expected, template, error_mode: :rigid) + assert_template_result(expected, template, error_mode: :rigid) end def test_inline_snippet_local_scope_takes_precedence @@ -467,83 +467,94 @@ def test_render_snippets_as_arguments assert_template_result(expected, template) end - # def test_render_inline_snippet_inside_loop - # template = <<~LIQUID.strip - # {% assign color_scheme = 'dark' %} - # {% assign array = '1,2,3' | split: ',' %} - - # {% for i in array %} - # {% snippet header %} - #
    - # {{ message }} {{ i }} - #
    - # {% endsnippet %} - # {% endfor %} - - # {% render header, ..., message: '👉' %} - # LIQUID - # expected = <<~OUTPUT - - #
    - # 👉 3 - #
    - # OUTPUT - - # assert_template_result(expected, template) - # end - - # def test_render_inline_snippet_forloop - # template = <<~LIQUID.strip - # {% snippet item %} - #
  • {{ forloop.index }}: {{ item }}
  • - # {% endsnippet %} - - # {% assign items = "A,B,C" | split: "," %} - # {%- render item for items -%} - # LIQUID - # expected = <<~OUTPUT - - #
  • 1: A
  • - - #
  • 2: B
  • - - #
  • 3: C
  • - # OUTPUT - - # assert_template_result(expected, template) - # end - - # def test_render_inline_snippet_with - # template = <<~LIQUID.strip - # {% snippet header %} - #
    {{ header }}
    - # {% endsnippet %} - - # {% assign product = "Apple" %} - # {%- render header with product -%} - # LIQUID - # expected = <<~OUTPUT - - #
    Apple
    - # OUTPUT - - # assert_template_result(expected, template) - # end - - # def test_render_inline_snippet_alias - # template = <<~LIQUID.strip - # {% snippet product_card %} - #
    {{ item }}
    - # {% endsnippet %} - - # {% assign featured = "Apple" %} - # {%- render product_card with featured as item -%} - # LIQUID - # expected = <<~OUTPUT - - #
    Apple
    - # OUTPUT - - # assert_template_result(expected, template) - # end + def test_render_inline_snippet_forloop + template = <<~LIQUID.strip + {% snippet item %} +
  • {{ forloop.index }}: {{ item }}
  • + {% endsnippet %} + + {% assign items = "A,B,C" | split: "," %} + {%- render item for items -%} + LIQUID + expected = <<~OUTPUT + + + +
  • 1: A
  • + +
  • 2: B
  • + +
  • 3: C
  • + OUTPUT + + assert_template_result(expected, template) + end + + def test_render_inline_snippet_with + template = <<~LIQUID.strip + {% snippet header %} +
    {{ header }}
    + {% endsnippet %} + + {% assign product = "Apple" %} + {%- render header with product -%} + LIQUID + expected = <<~OUTPUT + + + +
    Apple
    + OUTPUT + + assert_template_result(expected, template) + end + + def test_render_inline_snippet_alias + template = <<~LIQUID.strip + {% snippet product_card %} +
    {{ item }}
    + {% endsnippet %} + + {% assign featured = "Apple" %} + {%- render product_card with featured as item -%} + LIQUID + expected = <<~OUTPUT + + + +
    Apple
    + OUTPUT + + assert_template_result(expected, template) + end + + def test_render_inline_snippet_inside_loop + template = <<~LIQUID.strip + {% assign color_scheme = 'dark' %} + {% assign array = '1,2,3' | split: ',' %} + + {% for i in array %} + {% snippet header %} +
    + {{ message }} {{ i }} +
    + {% endsnippet %} + {% endfor %} + + {% render header, ..., message: '👉' %} + LIQUID + expected = <<~OUTPUT + + + + + + +
    + 👉#{" "} +
    + OUTPUT + + assert_template_result(expected, template) + end end From b5ecd4d0f83f85c91334d012f5ce43121dfd1d36 Mon Sep 17 00:00:00 2001 From: Julia Boutin Date: Mon, 20 Oct 2025 10:53:01 -0600 Subject: [PATCH 07/19] Remove inline snippet specific example files --- Gemfile | 4 -- Gemfile.lock | 74 --------------------------- example/server/parser_attempt.rb | 59 --------------------- example/server/templates/index.liquid | 44 ++-------------- 4 files changed, 3 insertions(+), 178 deletions(-) delete mode 100644 Gemfile.lock delete mode 100644 example/server/parser_attempt.rb diff --git a/Gemfile b/Gemfile index fc327c921..a404c0d4b 100644 --- a/Gemfile +++ b/Gemfile @@ -29,7 +29,3 @@ group :test do gem 'rubocop-shopify', '~> 2.12.0', require: false gem 'rubocop-performance', require: false end - -group :development do - gem "webrick" -end diff --git a/Gemfile.lock b/Gemfile.lock deleted file mode 100644 index 381c2b7e3..000000000 --- a/Gemfile.lock +++ /dev/null @@ -1,74 +0,0 @@ -PATH - remote: . - specs: - liquid (5.8.7) - bigdecimal - strscan (>= 3.1.1) - -GEM - remote: https://rubygems.org/ - specs: - ast (2.4.2) - base64 (0.2.0) - benchmark-ips (2.13.0) - bigdecimal (3.2.3) - json (2.7.2) - language_server-protocol (3.17.0.3) - lru_redux (1.1.0) - memory_profiler (1.0.1) - minitest (5.22.3) - parallel (1.24.0) - parser (3.3.0.5) - ast (~> 2.4.1) - racc - racc (1.7.3) - rainbow (3.1.1) - rake (13.2.1) - regexp_parser (2.9.0) - rexml (3.2.6) - rubocop (1.61.0) - json (~> 2.3) - language_server-protocol (>= 3.17.0) - parallel (~> 1.10) - parser (>= 3.3.0.2) - rainbow (>= 2.2.2, < 4.0) - regexp_parser (>= 1.8, < 3.0) - rexml (>= 3.2.5, < 4.0) - rubocop-ast (>= 1.30.0, < 2.0) - ruby-progressbar (~> 1.7) - unicode-display_width (>= 2.4.0, < 3.0) - rubocop-ast (1.31.2) - parser (>= 3.3.0.4) - rubocop-performance (1.19.1) - rubocop (>= 1.7.0, < 2.0) - rubocop-ast (>= 0.4.0) - rubocop-shopify (2.12.0) - rubocop (~> 1.44) - ruby-progressbar (1.13.0) - stackprof (0.2.26) - strscan (3.1.5) - terminal-table (3.0.2) - unicode-display_width (>= 1.1.1, < 3) - unicode-display_width (2.5.0) - webrick (1.8.1) - -PLATFORMS - ruby - -DEPENDENCIES - base64 - benchmark-ips - liquid! - lru_redux - memory_profiler - minitest - rake (~> 13.0) - rubocop (~> 1.61.0) - rubocop-performance - rubocop-shopify (~> 2.12.0) - stackprof - terminal-table - webrick - -BUNDLED WITH - 2.5.7 diff --git a/example/server/parser_attempt.rb b/example/server/parser_attempt.rb deleted file mode 100644 index 68e214dab..000000000 --- a/example/server/parser_attempt.rb +++ /dev/null @@ -1,59 +0,0 @@ -# frozen_string_literal: true - -require 'bundler/inline' - -gemfile(true) do - source "https://rubygems.org" - gem 'liquid' -end - -require 'liquid' - -class Parser - def initialize(template) - @template = template - end - - def parse - @parsed_template = Liquid::Template.parse(@template) - end - - def test_parse - document = @parsed_template.root - - variables = [] - - if document.is_a?(Liquid::Document) - body = document.body - - if body.is_a?(Liquid::BlockBody) - body.nodelist.each do |node| - next unless node.is_a?(Liquid::Variable) - - puts node.inspect - variable_name = node.name.name - variables << variable_name - end - end - end - puts "Variables: #{variables}" - end - - def render - @parsed_template.render - end -end - -starter_template = "{{ foo }}" -starter_template_2 = "{{foo}}, {{bar}}" -starter_template_2_1 = "{{ foo }} and {{ bar }}" -starter_template_3 = "{% assign foo = 'bar' %}{{ foo }}" -# Let's start small here -template = <<~LIQUID - {% assign foo = 'bar' %} - {{ foo }} -LIQUID - -parser = Parser.new(starter_template) -parser.parse -parser.test_parse diff --git a/example/server/templates/index.liquid b/example/server/templates/index.liquid index a7a637dbf..e18cc306d 100644 --- a/example/server/templates/index.liquid +++ b/example/server/templates/index.liquid @@ -1,43 +1,5 @@ - - - - - - Inline Snippets +

    Hello world!

    - -
    +

    It is {{ date }}

    - {% assign foo = true %} - {% assign linktext = "variable" %} - - {% snippet main %} - {% assign foo = false %} -

    Hi {{ arg | upcase }}!!!

    - -

    This is an inline snippet

    - -
      -
    • wow a {{ linktext }}
    • -
    • 1 + 1 = {{ 1 | plus: 1 }}
    • -
    • {% if true %}Yes!{% endif %}
    • -
    • foo = {% if foo %}true{%else%}false{% endif %}
    • -
    • {{ missing_var | default: 'fallback' }}
    • -
    - {% endsnippet %} - - {% render main, arg: 'lsf', ... %} - {{ foo }} - - {% snippet listitem %} -
  • {{ forloop.index }}: {{ emoji }}
  • - {% endsnippet %} - - {% assign emojis = "🌼,🌳,🌸" | split: "," %} -
      - {%- render listitem for emojis as emoji -%} -
    - -
    - - +

    Check out the Products screen

    From 9409dd8f4a0d7985eeed2739736c65260ef76630 Mon Sep 17 00:00:00 2001 From: Julia Boutin Date: Mon, 20 Oct 2025 12:57:36 -0600 Subject: [PATCH 08/19] Render arguments should maintain correct precedence --- lib/liquid.rb | 1 + lib/liquid/tags/render.rb | 42 +- test/integration/tags/snippet_test.rb | 1393 ++++++++++++++++++------- 3 files changed, 1056 insertions(+), 380 deletions(-) diff --git a/lib/liquid.rb b/lib/liquid.rb index 09cafe947..2bc0b4a72 100644 --- a/lib/liquid.rb +++ b/lib/liquid.rb @@ -40,6 +40,7 @@ module Liquid QuotedString = /"[^"]*"|'[^']*'/ QuotedFragment = /#{QuotedString}|(?:[^\s,\|'"]|#{QuotedString})+/o TagAttributes = /(\w[\w-]*)\s*\:\s*(#{QuotedFragment})/o + ContextInheritance = /\.\.\./ AnyStartingTag = /#{TagStart}|#{VariableStart}/o PartialTemplateParser = /#{TagStart}.*?#{TagEnd}|#{VariableStart}.*?#{VariableIncompleteEnd}/om TemplateParser = /(#{PartialTemplateParser}|#{AnyStartingTag})/om diff --git a/lib/liquid/tags/render.rb b/lib/liquid/tags/render.rb index db8fa768e..dd1a7a18f 100644 --- a/lib/liquid/tags/render.rb +++ b/lib/liquid/tags/render.rb @@ -42,10 +42,6 @@ def for_loop? @is_for_loop end - def inherit_context? - @inherit_context - end - def render_to_output_buffer(context, output) render_tag(context, output) end @@ -77,20 +73,21 @@ def render_tag(context, output) inner_context.partial = true end - if is_inline && inherit_context? - context.scopes.each do |scope| - scope.each do |key, value| - inner_context[key] = value - end - end - end + inner_context['forloop'] = forloop if forloop @attributes.each do |key, value| - inner_context[key] = context.evaluate(value) + if key == "..." && is_inline + context.scopes.each do |scope| + scope.each do |k, v| + inner_context[k] = v + end + end + else + inner_context[key] = context.evaluate(value) + end end inner_context[context_variable_name] = var unless var.nil? - inner_context['forloop'] = forloop if forloop partial.render_to_output_buffer(inner_context, output) forloop&.send(:increment!) @@ -119,14 +116,6 @@ def rigid_parse(markup) p.consume?(:comma) - @inherit_context = false - # ... inline snippets syntax - if p.consume?(:dotdotdot) - p.consume?(:comma) - - @inherit_context = true - end - @attributes = {} while p.look(:id) key = p.consume @@ -162,11 +151,16 @@ def lax_parse(markup) @variable_name_expr = variable_name ? parse_expression(variable_name) : nil @template_name_expr = parse_expression(template_name) @is_for_loop = (with_or_for == FOR) - @inherit_context = markup.include?('...') @attributes = {} - markup.scan(TagAttributes) do |key, value| - @attributes[key] = parse_expression(value) + markup.scan(/(#{ContextInheritance})|#{TagAttributes.source}/) do |context_marker, key, value| + if context_marker + @attributes.delete("...") + @attributes["..."] = true + elsif key && value + @attributes.delete(key) + @attributes[key] = parse_expression(value) + end end end diff --git a/test/integration/tags/snippet_test.rb b/test/integration/tags/snippet_test.rb index 8699b359e..38fe599a1 100644 --- a/test/integration/tags/snippet_test.rb +++ b/test/integration/tags/snippet_test.rb @@ -5,556 +5,1237 @@ class SnippetTest < Minitest::Test include Liquid - def test_valid_inline_snippet - template = <<~LIQUID.strip - {% snippet input %} + class LaxMode < SnippetTest + def test_valid_inline_snippet + template = <<~LIQUID.strip + {% snippet input %} + Hey + {% endsnippet %} + LIQUID + expected = '' + + assert_template_result(expected, template) + end + + def test_render_inline_snippet + template = <<~LIQUID.strip + {% snippet hey %} Hey - {% endsnippet %} - LIQUID - expected = '' + {% endsnippet %} - assert_template_result(expected, template) - end + {%- render hey -%} + LIQUID + expected = <<~OUTPUT - def test_render_inline_snippet - template = <<~LIQUID.strip - {% snippet hey %} - Hey - {% endsnippet %} + Hey + OUTPUT - {%- render hey -%} - LIQUID - expected = <<~OUTPUT + assert_template_result(expected, template) + end - Hey - OUTPUT + def test_render_inline_snippet_with_variable + template = <<~LIQUID.strip + {% snippet hey %} +

    Today is {{ "hello" | capitalize }}

    + {% endsnippet %} - assert_template_result(expected, template) - end + {%- render hey -%} + LIQUID + expected = <<~OUTPUT - def test_render_inline_snippet_with_variable - template = <<~LIQUID.strip - {% snippet hey %} -

    Today is {{ "hello" | capitalize }}

    - {% endsnippet %} +

    Today is Hello

    + OUTPUT - {%- render hey -%} - LIQUID - expected = <<~OUTPUT + assert_template_result(expected, template) + end -

    Today is Hello

    - OUTPUT + def test_render_multiple_inline_snippets + template = <<~LIQUID.strip + {% snippet input %} + + {% endsnippet %} - assert_template_result(expected, template) - end + {% snippet banner %} + + Welcome to my store! + + {% endsnippet %} - def test_render_multiple_inline_snippets - template = <<~LIQUID.strip - {% snippet input %} - - {% endsnippet %} + {%- render input -%} + {%- render banner -%} + LIQUID + expected = <<~OUTPUT - {% snippet banner %} - - Welcome to my store! - - {% endsnippet %} - {%- render input -%} - {%- render banner -%} - LIQUID - expected = <<~OUTPUT + + + Welcome to my store! + + OUTPUT - + assert_template_result(expected, template) + end - - Welcome to my store! - - OUTPUT + def test_render_inline_snippet_with_argument + template = <<~LIQUID.strip + {% snippet input %} + + {% endsnippet %} - assert_template_result(expected, template) - end + {%- render input, type: "text" -%} + LIQUID + expected = <<~OUTPUT - def test_render_inline_snippet_with_argument - template = <<~LIQUID.strip - {% snippet input %} - - {% endsnippet %} + + OUTPUT - {%- render input, type: "text" -%} - LIQUID - expected = <<~OUTPUT + assert_template_result(expected, template) + end - - OUTPUT + def test_render_inline_snippet_with_doc_tag + template = <<~LIQUID.strip + {% snippet input %} + {% doc %} + @param {string} type - Input type. + {% enddoc %} - assert_template_result(expected, template) - end + + {% endsnippet %} - def test_render_inline_snippet_with_doc_tag - template = <<~LIQUID.strip - {% snippet input %} - {% doc %} - @param {string} type - Input type. - {% enddoc %} + {%- render input, type: "text" -%} + LIQUID + expected = <<~OUTPUT - - {% endsnippet %} - {%- render input, type: "text" -%} - LIQUID - expected = <<~OUTPUT + + OUTPUT + assert_template_result(expected, template) + end - - OUTPUT + def test_render_inline_snippet_with_evaluated_assign + template = <<~LIQUID.strip + {% snippet input %} +

    {{ greeting }}

    + {% endsnippet %} - assert_template_result(expected, template) - end + {%- assign greeting = "hello" | upcase -%} + {%- render input, greeting: greeting -%} + LIQUID + expected = <<~OUTPUT - def test_render_inline_snippet_with_evaluated_assign - template = <<~LIQUID.strip - {% snippet input %} -

    {{ greeting }}

    - {% endsnippet %} +

    HELLO

    + OUTPUT - {%- assign greeting = "hello" | upcase -%} - {%- render input, greeting: greeting -%} - LIQUID - expected = <<~OUTPUT + assert_template_result(expected, template) + end -

    HELLO

    - OUTPUT + def test_render_inline_snippet_with_multiple_arguments + template = <<~LIQUID.strip + {% snippet input %} + {% doc %} + @param {string} type - Input type. + @param {string} value - Input value. + {% enddoc %} - assert_template_result(expected, template) - end + + {% endsnippet %} - def test_render_inline_snippet_with_multiple_arguments - template = <<~LIQUID.strip - {% snippet input %} - {% doc %} - @param {string} type - Input type. - @param {string} value - Input value. - {% enddoc %} + {%- render input, type: "text", value: "Hello" -%} + LIQUID + expected = <<~OUTPUT - - {% endsnippet %} - {%- render input, type: "text", value: "Hello" -%} - LIQUID - expected = <<~OUTPUT + + OUTPUT + assert_template_result(expected, template) + end - - OUTPUT + def test_render_inline_snippets_using_same_argument_name + template = <<~LIQUID.strip + {% snippet input %} + + {% endsnippet %} - assert_template_result(expected, template) - end + {% snippet inputs %} + + {% endsnippet %} - def test_render_inline_snippets_using_same_argument_name - template = <<~LIQUID.strip - {% snippet input %} - - {% endsnippet %} + {%- render input, type: "text" -%} + {%- render inputs, type: "password", value: "pass" -%} + LIQUID - {% snippet inputs %} - - {% endsnippet %} + expected = <<~OUTPUT - {%- render input, type: "text" -%} - {%- render inputs, type: "password", value: "pass" -%} - LIQUID - expected = <<~OUTPUT + + + OUTPUT - + assert_template_result(expected, template) + end - - OUTPUT + def test_render_inline_snippet_empty_string_when_missing_argument + template = <<~LIQUID.strip + {% snippet input %} + {% doc %} + @param {string} type - Input type. + @param {string} value - Input value. + {% enddoc %} - assert_template_result(expected, template) - end + + {% endsnippet %} - def test_render_inline_snippet_empty_string_when_missing_argument - template = <<~LIQUID.strip - {% snippet input %} - {% doc %} - @param {string} type - Input type. - @param {string} value - Input value. - {% enddoc %} + {%- render input, type: "text" -%} + LIQUID + expected = <<~OUTPUT - - {% endsnippet %} - {%- render input, type: "text" -%} - LIQUID - expected = <<~OUTPUT + + OUTPUT + assert_template_result(expected, template) + end - - OUTPUT + def test_render_inline_snippet_shouldnt_leak_context + template = <<~LIQUID.strip + {% snippet input %} + {% doc %} + @param {string} type - Input type. + @param {string} value - Input value. + {% enddoc %} - assert_template_result(expected, template) - end + + {% endsnippet %} - def test_render_inline_snippet_shouldnt_leak_context - template = <<~LIQUID.strip - {% snippet input %} - {% doc %} - @param {string} type - Input type. - @param {string} value - Input value. - {% enddoc %} + {%- render input, type: "text", value: "Hello" -%} - - {% endsnippet %} + {{ type }} + {{ value }} + LIQUID + expected = <<~OUTPUT - {%- render input, type: "text", value: "Hello" -%} - {{ type }} - {{ value }} - LIQUID - expected = <<~OUTPUT + + OUTPUT - + assert_template_result(expected, template) + end - OUTPUT + def test_render_multiple_inline_snippets_without_leaking_context + template = <<~LIQUID.strip + {% snippet input %} + {% doc %} + @param {string} type - Input type. + {% enddoc %} - assert_template_result(expected, template) - end + + {% endsnippet %} - def test_render_multiple_inline_snippets_without_leaking_context - template = <<~LIQUID.strip - {% snippet input %} - {% doc %} - @param {string} type - Input type. - {% enddoc %} + {% snippet no_leak %} + + {% endsnippet %} - - {% endsnippet %} + {%- render input, type: "text" -%} + {%- render no_leak -%} + LIQUID + expected = <<~OUTPUT - {% snippet no_leak %} - - {% endsnippet %} - {%- render input, type: "text" -%} - {%- render no_leak -%} - LIQUID - expected = <<~OUTPUT + + + OUTPUT - + assert_template_result(expected, template) + end - - OUTPUT + def test_render_inline_snippet_without_outside_context + template = <<~LIQUID.strip + {% assign color_scheme = 'dark' %} - assert_template_result(expected, template) - end + {% snippet header %} +
    + {{ message }} +
    + {% endsnippet %} - def test_render_inline_snippet_without_outside_context - template = <<~LIQUID.strip - {% assign color_scheme = 'dark' %} - {% snippet header %} -
    - {{ message }} -
    - {% endsnippet %} + {% render header, message: 'Welcome!' %} + LIQUID + expected = <<~OUTPUT - {% render header, message: 'Welcome!' %} - LIQUID - expected = <<~OUTPUT +
    + Welcome! +
    + OUTPUT + assert_template_result(expected, template) + end -
    - Welcome! -
    - OUTPUT + def test_render_inline_snippet_with_outside_context + template = <<~LIQUID.strip + {% assign color_scheme = 'dark' %} - assert_template_result(expected, template) - end + {% snippet header %} +
    + {{ message }} +
    + {% endsnippet %} - def test_render_inline_snippet_with_outside_context - template = <<~LIQUID.strip - {% assign color_scheme = 'dark' %} - {% snippet header %} -
    - {{ message }} -
    - {% endsnippet %} + {% render header, ..., message: 'Welcome!' %} + LIQUID + expected = <<~OUTPUT - {% render header, ..., message: 'Welcome!' %} - LIQUID - expected = <<~OUTPUT +
    + Welcome! +
    + OUTPUT + assert_template_result(expected, template) + end -
    - Welcome! -
    - OUTPUT + def test_inline_snippet_local_scope_takes_precedence + template = <<~LIQUID + {% assign color_scheme = 'dark' %} - assert_template_result(expected, template) - end + {% snippet header %} + {% assign color_scheme = 'light' %} +
    + {{ message }} +
    + {% endsnippet %} - def test_render_inline_snippet_with_outside_context_rigid - template = <<~LIQUID.strip - {% assign color_scheme = 'dark' %} + {{ color_scheme }} - {% snippet header %} -
    - {{ message }} -
    - {% endsnippet %} + {% render header, ..., message: 'Welcome!' %} + {{ color_scheme }} + LIQUID + expected = <<~OUTPUT - {% render header, ..., message: 'Welcome!' %} - LIQUID - expected = <<~OUTPUT + dark -
    - Welcome! -
    - OUTPUT +
    + Welcome! +
    - assert_template_result(expected, template, error_mode: :rigid) - end - def test_inline_snippet_local_scope_takes_precedence - template = <<~LIQUID - {% assign color_scheme = 'dark' %} + dark + OUTPUT - {% snippet header %} - {% assign color_scheme = 'light' %} -
    - {{ message }} -
    - {% endsnippet %} + assert_template_result(expected, template) + end - {{ color_scheme }} + def test_render_inline_snippet_with_correct_argument_precedence + template = <<~LIQUID.strip + {% assign color_scheme = 'dark' %} + {% assign message = 'Goodbye!' %} - {% render header, ..., message: 'Welcome!' %} + {% snippet header %} +
    + {{ message }} +
    + {% endsnippet %} - {{ color_scheme }} - LIQUID - expected = <<~OUTPUT + {% render header, message: 'Welcome!', ... %} + LIQUID + expected = <<~OUTPUT - dark -
    - Welcome! -
    +
    + Goodbye! +
    + OUTPUT - dark - OUTPUT + assert_template_result(expected, template) + end + + def test_render_inline_snippet_with_correct_argument_order + template = <<~LIQUID.strip + {% assign color_scheme = 'dark' %} + {% assign message = 'Goodbye!' %} + + {% snippet header %} +
    + {{ message }} +
    + {% endsnippet %} - assert_template_result(expected, template) - end - def test_render_captured_snippet - template = <<~LIQUID - {% assign color_scheme = 'dark' %} + {% render header, ..., message: 'Welcome!' %} + LIQUID + expected = <<~OUTPUT - {% snippet header %} -
    - {{ message }} -
    - {% endsnippet %} - {% capture up_header %} - {%- render header, ..., message: 'Welcome!' -%} - {% endcapture %} - {{ up_header | upcase }} - {{ header | upcase }} - {{ header }} - LIQUID - expected = <<~OUTPUT +
    + Welcome! +
    + OUTPUT + + assert_template_result(expected, template) + end + + def test_render_inline_snippet_with_correct_duplicate_argument_precedence + template = <<~LIQUID.strip + {% assign color_scheme = 'dark' %} + {% assign message = 'Goodbye!' %} + + {% snippet header %} +
    + {{ message }} +
    + {% endsnippet %} + + + {% render header, message: 'Welcome!', ..., message: 'Hi!' %} + LIQUID + expected = <<~OUTPUT + -
    - WELCOME! -
    + +
    + Hi! +
    + OUTPUT + + assert_template_result(expected, template) + end + + def test_render_captured_snippet + template = <<~LIQUID + {% assign color_scheme = 'dark' %} + + {% snippet header %} +
    + {{ message }} +
    + {% endsnippet %} + + {% capture up_header %} + {%- render header, ..., message: 'Welcome!' -%} + {% endcapture %} + + {{ up_header | upcase }} + + {{ header | upcase }} + + {{ header }} + LIQUID + expected = <<~OUTPUT + + + + + + + +
    + WELCOME! +
    + + + SNIPPETDROP + + SnippetDrop + OUTPUT + + assert_template_result(expected, template) + end + + def test_render_snippets_as_arguments + template = <<~LIQUID.strip + {% assign color_scheme = 'dark' %} + + {% snippet header %} +
    + {{ message }} +
    + {% endsnippet %} + + {% snippet main %} + {% assign color_scheme = 'auto' %} + +
    + {% render header, ..., message: 'Welcome!' %} +
    + {% endsnippet %} + + {% render main, header: header %} + LIQUID + + expected = <<~OUTPUT - SNIPPETDROP - SnippetDrop - OUTPUT - assert_template_result(expected, template) + + + + + +
    + +
    + Welcome! +
    + +
    + OUTPUT + + assert_template_result(expected, template) + end + + def test_render_inline_snippet_forloop + template = <<~LIQUID.strip + {% snippet item %} +
  • {{ forloop.index }}: {{ item }}
  • + {% endsnippet %} + + {% assign items = "A,B,C" | split: "," %} + {%- render item for items -%} + LIQUID + expected = <<~OUTPUT + + + +
  • 1: A
  • + +
  • 2: B
  • + +
  • 3: C
  • + OUTPUT + + assert_template_result(expected, template) + end + + def test_render_inline_snippet_with + template = <<~LIQUID.strip + {% snippet header %} +
    {{ header }}
    + {% endsnippet %} + + {% assign product = "Apple" %} + {%- render header with product -%} + LIQUID + expected = <<~OUTPUT + + + +
    Apple
    + OUTPUT + + assert_template_result(expected, template) + end + + def test_render_inline_snippet_alias + template = <<~LIQUID.strip + {% snippet product_card %} +
    {{ item }}
    + {% endsnippet %} + + {% assign featured = "Apple" %} + {%- render product_card with featured as item -%} + LIQUID + expected = <<~OUTPUT + + + +
    Apple
    + OUTPUT + + assert_template_result(expected, template) + end + + def test_render_inline_snippet_inside_loop + template = <<~LIQUID.strip + {% assign color_scheme = 'dark' %} + {% assign array = '1,2,3' | split: ',' %} + + {% for i in array %} + {% snippet header %} +
    + {{ message }} {{ i }} +
    + {% endsnippet %} + {% endfor %} + + {% render header, ..., message: '👉' %} + LIQUID + expected = <<~OUTPUT + + + + + + +
    + 👉#{" "} +
    + OUTPUT + + assert_template_result(expected, template) + end end - def test_render_snippets_as_arguments - template = <<~LIQUID.strip - {% assign color_scheme = 'dark' %} + class RigidMode < SnippetTest + def test_valid_inline_snippet + template = <<~LIQUID.strip + {% snippet input %} + Hey + {% endsnippet %} + LIQUID + expected = '' + + assert_template_result(expected, template, error_mode: :rigid) + end + + def test_render_inline_snippet + template = <<~LIQUID.strip + {% snippet hey %} + Hey + {% endsnippet %} + + {%- render hey -%} + LIQUID + expected = <<~OUTPUT + + Hey + OUTPUT + + assert_template_result(expected, template, error_mode: :rigid) + end + + def test_render_inline_snippet_with_variable + template = <<~LIQUID.strip + {% snippet hey %} +

    Today is {{ "hello" | capitalize }}

    + {% endsnippet %} + + {%- render hey -%} + LIQUID + expected = <<~OUTPUT + +

    Today is Hello

    + OUTPUT + + assert_template_result(expected, template, error_mode: :rigid) + end + + def test_render_multiple_inline_snippets + template = <<~LIQUID.strip + {% snippet input %} + + {% endsnippet %} + + {% snippet banner %} + + Welcome to my store! + + {% endsnippet %} + + {%- render input -%} + {%- render banner -%} + LIQUID + expected = <<~OUTPUT + + + + + + + Welcome to my store! + + OUTPUT + + assert_template_result(expected, template, error_mode: :rigid) + end + + def test_render_inline_snippet_with_argument + template = <<~LIQUID.strip + {% snippet input %} + + {% endsnippet %} + + {%- render input, type: "text" -%} + LIQUID + expected = <<~OUTPUT + + + OUTPUT + + assert_template_result(expected, template, error_mode: :rigid) + end + + def test_render_inline_snippet_with_doc_tag + template = <<~LIQUID.strip + {% snippet input %} + {% doc %} + @param {string} type - Input type. + {% enddoc %} + + + {% endsnippet %} + + {%- render input, type: "text" -%} + LIQUID + expected = <<~OUTPUT + + + + + OUTPUT + + assert_template_result(expected, template, error_mode: :rigid) + end + + def test_render_inline_snippet_with_evaluated_assign + template = <<~LIQUID.strip + {% snippet input %} +

    {{ greeting }}

    + {% endsnippet %} + + {%- assign greeting = "hello" | upcase -%} + {%- render input, greeting: greeting -%} + LIQUID + expected = <<~OUTPUT + +

    HELLO

    + OUTPUT + + assert_template_result(expected, template, error_mode: :rigid) + end + + def test_render_inline_snippet_with_multiple_arguments + template = <<~LIQUID.strip + {% snippet input %} + {% doc %} + @param {string} type - Input type. + @param {string} value - Input value. + {% enddoc %} + + + {% endsnippet %} + + {%- render input, type: "text", value: "Hello" -%} + LIQUID + expected = <<~OUTPUT + + + + + OUTPUT + + assert_template_result(expected, template, error_mode: :rigid) + end - {% snippet header %} + def test_render_inline_snippets_using_same_argument_name + template = <<~LIQUID.strip + {% snippet input %} + + {% endsnippet %} + + {% snippet inputs %} + + {% endsnippet %} + + {%- render input, type: "text" -%} + {%- render inputs, type: "password", value: "pass" -%} + LIQUID + + expected = <<~OUTPUT + + + + + + + OUTPUT + + assert_template_result(expected, template, error_mode: :rigid) + end + + def test_render_inline_snippet_empty_string_when_missing_argument + template = <<~LIQUID.strip + {% snippet input %} + {% doc %} + @param {string} type - Input type. + @param {string} value - Input value. + {% enddoc %} + + + {% endsnippet %} + + {%- render input, type: "text" -%} + LIQUID + expected = <<~OUTPUT + + + + + OUTPUT + + assert_template_result(expected, template, error_mode: :rigid) + end + + def test_render_inline_snippet_shouldnt_leak_context + template = <<~LIQUID.strip + {% snippet input %} + {% doc %} + @param {string} type - Input type. + @param {string} value - Input value. + {% enddoc %} + + + {% endsnippet %} + + {%- render input, type: "text", value: "Hello" -%} + + {{ type }} + {{ value }} + LIQUID + expected = <<~OUTPUT + + + + + + OUTPUT + + assert_template_result(expected, template, error_mode: :rigid) + end + + def test_render_multiple_inline_snippets_without_leaking_context + template = <<~LIQUID.strip + {% snippet input %} + {% doc %} + @param {string} type - Input type. + {% enddoc %} + + + {% endsnippet %} + + {% snippet no_leak %} + + {% endsnippet %} + + {%- render input, type: "text" -%} + {%- render no_leak -%} + LIQUID + expected = <<~OUTPUT + + + + + + + + + OUTPUT + + assert_template_result(expected, template, error_mode: :rigid) + end + + def test_render_inline_snippet_without_outside_context + template = <<~LIQUID.strip + {% assign color_scheme = 'dark' %} + + {% snippet header %}
    {{ message }}
    - {% endsnippet %} + {% endsnippet %} - {% snippet main %} - {% assign color_scheme = 'auto' %} -
    - {% render header, ..., message: 'Welcome!' %} -
    - {% endsnippet %} + {% render header, message: 'Welcome!' %} + LIQUID + expected = <<~OUTPUT - {% render main, header: header %} - LIQUID - expected = <<~OUTPUT +
    + Welcome! +
    + OUTPUT + assert_template_result(expected, template, error_mode: :rigid) + end + + def test_render_inline_snippet_with_outside_context + template = <<~LIQUID.strip + {% assign color_scheme = 'dark' %} + + {% snippet header %} +
    + {{ message }} +
    + {% endsnippet %} + + + {% render header, ..., message: 'Welcome!' %} + LIQUID + expected = <<~OUTPUT -
    -
    + +
    Welcome!
    + OUTPUT -
    - OUTPUT + assert_template_result(expected, template, error_mode: :rigid) + end - assert_template_result(expected, template) - end + def test_inline_snippet_local_scope_takes_precedence + template = <<~LIQUID + {% assign color_scheme = 'dark' %} + + {% snippet header %} + {% assign color_scheme = 'light' %} +
    + {{ message }} +
    + {% endsnippet %} - def test_render_inline_snippet_forloop - template = <<~LIQUID.strip - {% snippet item %} -
  • {{ forloop.index }}: {{ item }}
  • - {% endsnippet %} + {{ color_scheme }} - {% assign items = "A,B,C" | split: "," %} - {%- render item for items -%} - LIQUID - expected = <<~OUTPUT + {% render header, ..., message: 'Welcome!' %} + {{ color_scheme }} + LIQUID + expected = <<~OUTPUT -
  • 1: A
  • -
  • 2: B
  • -
  • 3: C
  • - OUTPUT + dark - assert_template_result(expected, template) - end - def test_render_inline_snippet_with - template = <<~LIQUID.strip - {% snippet header %} -
    {{ header }}
    - {% endsnippet %} - {% assign product = "Apple" %} - {%- render header with product -%} - LIQUID - expected = <<~OUTPUT +
    + Welcome! +
    + dark + OUTPUT -
    Apple
    - OUTPUT + assert_template_result(expected, template, error_mode: :rigid) + end - assert_template_result(expected, template) - end + def test_render_inline_snippet_with_correct_argument_precedence + template = <<~LIQUID.strip + {% assign color_scheme = 'dark' %} + {% assign message = 'Goodbye!' %} - def test_render_inline_snippet_alias - template = <<~LIQUID.strip - {% snippet product_card %} -
    {{ item }}
    - {% endsnippet %} + {% snippet header %} +
    + {{ message }} +
    + {% endsnippet %} - {% assign featured = "Apple" %} - {%- render product_card with featured as item -%} - LIQUID - expected = <<~OUTPUT + {% render header, message: 'Welcome!', ... %} + LIQUID + expected = <<~OUTPUT -
    Apple
    - OUTPUT - assert_template_result(expected, template) - end - def test_render_inline_snippet_inside_loop - template = <<~LIQUID.strip - {% assign color_scheme = 'dark' %} - {% assign array = '1,2,3' | split: ',' %} - {% for i in array %} - {% snippet header %} -
    - {{ message }} {{ i }} -
    - {% endsnippet %} - {% endfor %} - {% render header, ..., message: '👉' %} - LIQUID - expected = <<~OUTPUT +
    + Goodbye! +
    + OUTPUT + + assert_template_result(expected, template, error_mode: :rigid) + end + + def test_render_inline_snippet_with_correct_argument_order + template = <<~LIQUID.strip + {% assign color_scheme = 'dark' %} + {% assign message = 'Goodbye!' %} + + {% snippet header %} +
    + {{ message }} +
    + {% endsnippet %} + + + {% render header, ..., message: 'Welcome!' %} + LIQUID + expected = <<~OUTPUT + + + + + + + +
    + Welcome! +
    + OUTPUT + + assert_template_result(expected, template, error_mode: :rigid) + end + + def test_render_inline_snippet_with_correct_duplicate_argument_precedence + template = <<~LIQUID.strip + {% assign color_scheme = 'dark' %} + {% assign message = 'Goodbye!' %} + + {% snippet header %} +
    + {{ message }} +
    + {% endsnippet %} + + + {% render header, message: 'Welcome!', ..., message: 'Hi!' %} + LIQUID + expected = <<~OUTPUT + + + + + + + +
    + Hi! +
    + OUTPUT + + assert_template_result(expected, template, error_mode: :rigid) + end + + def test_render_captured_snippet + template = <<~LIQUID + {% assign color_scheme = 'dark' %} + + {% snippet header %} +
    + {{ message }} +
    + {% endsnippet %} + + {% capture up_header %} + {%- render header, ..., message: 'Welcome!' -%} + {% endcapture %} + + {{ up_header | upcase }} + + {{ header | upcase }} + + {{ header }} + LIQUID + expected = <<~OUTPUT + + + + + + + +
    + WELCOME! +
    + + + SNIPPETDROP + + SnippetDrop + OUTPUT + + assert_template_result(expected, template, error_mode: :rigid) + end + + def test_render_snippets_as_arguments + template = <<~LIQUID.strip + {% assign color_scheme = 'dark' %} + + {% snippet header %} +
    + {{ message }} +
    + {% endsnippet %} + + {% snippet main %} + {% assign color_scheme = 'auto' %} + +
    + {% render header, ..., message: 'Welcome!' %} +
    + {% endsnippet %} + + {% render main, header: header %} + LIQUID + + expected = <<~OUTPUT -
    - 👉#{" "} -
    - OUTPUT - assert_template_result(expected, template) + + + +
    + +
    + Welcome! +
    + +
    + OUTPUT + + assert_template_result(expected, template, error_mode: :rigid) + end + + def test_render_inline_snippet_forloop + template = <<~LIQUID.strip + {% snippet item %} +
  • {{ forloop.index }}: {{ item }}
  • + {% endsnippet %} + + {% assign items = "A,B,C" | split: "," %} + {%- render item for items -%} + LIQUID + expected = <<~OUTPUT + + + +
  • 1: A
  • + +
  • 2: B
  • + +
  • 3: C
  • + OUTPUT + + assert_template_result(expected, template, error_mode: :rigid) + end + + def test_render_inline_snippet_with + template = <<~LIQUID.strip + {% snippet header %} +
    {{ header }}
    + {% endsnippet %} + + {% assign product = "Apple" %} + {%- render header with product -%} + LIQUID + expected = <<~OUTPUT + + + +
    Apple
    + OUTPUT + + assert_template_result(expected, template, error_mode: :rigid) + end + + def test_render_inline_snippet_alias + template = <<~LIQUID.strip + {% snippet product_card %} +
    {{ item }}
    + {% endsnippet %} + + {% assign featured = "Apple" %} + {%- render product_card with featured as item -%} + LIQUID + expected = <<~OUTPUT + + + +
    Apple
    + OUTPUT + + assert_template_result(expected, template, error_mode: :rigid) + end + + def test_render_inline_snippet_inside_loop + template = <<~LIQUID.strip + {% assign color_scheme = 'dark' %} + {% assign array = '1,2,3' | split: ',' %} + + {% for i in array %} + {% snippet header %} +
    + {{ message }} {{ i }} +
    + {% endsnippet %} + {% endfor %} + + {% render header, ..., message: '👉' %} + LIQUID + expected = <<~OUTPUT + + + + + + +
    + 👉#{" "} +
    + OUTPUT + + assert_template_result(expected, template, error_mode: :rigid) + end end end From fb6cf170999355eeef2b703d6efbaec68d252fb7 Mon Sep 17 00:00:00 2001 From: Julia Boutin Date: Tue, 21 Oct 2025 12:17:41 -0600 Subject: [PATCH 09/19] Implement resource limits and remove leftover string references --- lib/liquid.rb | 1 - lib/liquid/locales/en.yml | 2 +- lib/liquid/tags/render.rb | 11 ++++------- lib/liquid/tags/snippet.rb | 8 +++++--- test/integration/tags/snippet_test.rb | 8 ++++++++ 5 files changed, 18 insertions(+), 12 deletions(-) diff --git a/lib/liquid.rb b/lib/liquid.rb index 2bc0b4a72..09cafe947 100644 --- a/lib/liquid.rb +++ b/lib/liquid.rb @@ -40,7 +40,6 @@ module Liquid QuotedString = /"[^"]*"|'[^']*'/ QuotedFragment = /#{QuotedString}|(?:[^\s,\|'"]|#{QuotedString})+/o TagAttributes = /(\w[\w-]*)\s*\:\s*(#{QuotedFragment})/o - ContextInheritance = /\.\.\./ AnyStartingTag = /#{TagStart}|#{VariableStart}/o PartialTemplateParser = /#{TagStart}.*?#{TagEnd}|#{VariableStart}.*?#{VariableIncompleteEnd}/om TemplateParser = /(#{PartialTemplateParser}|#{AnyStartingTag})/om diff --git a/lib/liquid/locales/en.yml b/lib/liquid/locales/en.yml index b2196686f..6cff46a76 100644 --- a/lib/liquid/locales/en.yml +++ b/lib/liquid/locales/en.yml @@ -5,7 +5,7 @@ block_tag_unexpected_args: "Syntax Error in '%{tag}' - Valid syntax: {% %{tag} %}{% end%{tag} %}" assign: "Syntax Error in 'assign' - Valid syntax: assign [var] = [source]" capture: "Syntax Error in 'capture' - Valid syntax: capture [var]" - snippet: "Syntax Error in 'snippet' - Valid syntax: snippet [quoted string]" + snippet: "Syntax Error in 'snippet' - Valid syntax: snippet [var]" case: "Syntax Error in 'case' - Valid syntax: case [condition]" case_invalid_when: "Syntax Error in tag 'case' - Valid when condition: {% when [condition] [or condition2...] %}" case_invalid_else: "Syntax Error in tag 'case' - Valid else condition: {% else %} (no parameters) " diff --git a/lib/liquid/tags/render.rb b/lib/liquid/tags/render.rb index dd1a7a18f..3e9237f75 100644 --- a/lib/liquid/tags/render.rb +++ b/lib/liquid/tags/render.rb @@ -128,12 +128,9 @@ def rigid_parse(markup) end def rigid_template_name(p) - if p.look(:string) - p.consume(:string) - # inline snippets use variable identifiers - elsif p.look(:id) - p.consume(:id) - end + return p.consume(:string) if p.look(:string) + + p.consume(:id) if p.look(:id) end def strict_parse(markup) @@ -153,7 +150,7 @@ def lax_parse(markup) @is_for_loop = (with_or_for == FOR) @attributes = {} - markup.scan(/(#{ContextInheritance})|#{TagAttributes.source}/) do |context_marker, key, value| + markup.scan(/(\.\.\.)(?=\s|,|$)|#{TagAttributes.source}/) do |context_marker, key, value| if context_marker @attributes.delete("...") @attributes["..."] = true diff --git a/lib/liquid/tags/snippet.rb b/lib/liquid/tags/snippet.rb index aede69b78..cf6ef7569 100644 --- a/lib/liquid/tags/snippet.rb +++ b/lib/liquid/tags/snippet.rb @@ -2,7 +2,7 @@ module Liquid # @liquid_type tag - # @liquid_category theme + # @liquid_category variable # @liquid_name snippet # @liquid_summary # Creates a new inline snippet. @@ -14,8 +14,6 @@ module Liquid # {% endsnippet %} class Snippet < Block - SYNTAX = /(#{VariableSignature}+)/o - def initialize(tag_name, markup, options) super p = @parse_context.new_parser(markup) @@ -29,6 +27,10 @@ def initialize(tag_name, markup, options) def render_to_output_buffer(context, output) snippet_drop = SnippetDrop.new(@body) context.scopes.last[@to] = snippet_drop + + snippet_size = @body.nodelist.sum { |node| node.to_s.bytesize } + context.resource_limits.increment_assign_score(snippet_size) + output end diff --git a/test/integration/tags/snippet_test.rb b/test/integration/tags/snippet_test.rb index 38fe599a1..4906437dc 100644 --- a/test/integration/tags/snippet_test.rb +++ b/test/integration/tags/snippet_test.rb @@ -1238,4 +1238,12 @@ def test_render_inline_snippet_inside_loop assert_template_result(expected, template, error_mode: :rigid) end end + + class ResourceLimits < SnippetTest + def test_increment_assign_score_by_bytes_not_characters + t = Template.parse("{% snippet foo %}すごい{% endsnippet %}") + t.render! + assert_equal(9, t.resource_limits.assign_score) + end + end end From 96a44927b53b16b84826292b30aac5ce574553c0 Mon Sep 17 00:00:00 2001 From: Julia Boutin Date: Tue, 21 Oct 2025 16:53:15 -0600 Subject: [PATCH 10/19] 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 645dfa3a1..93cda9083 100644 --- a/lib/liquid/parser.rb +++ b/lib/liquid/parser.rb @@ -12,6 +12,14 @@ def jump(point) @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 3e9237f75..177c8f4dd 100644 --- a/lib/liquid/tags/render.rb +++ b/lib/liquid/tags/render.rb @@ -76,10 +76,25 @@ def render_tag(context, output) 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 @@ def rigid_parse(markup) 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 @@ def lax_parse(markup) @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 4906437dc..8830285ab 100644 --- a/test/integration/tags/snippet_test.rb +++ b/test/integration/tags/snippet_test.rb @@ -446,6 +446,154 @@ def test_render_inline_snippet_with_correct_duplicate_argument_precedence 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 @@ def test_render_inline_snippet_with_correct_duplicate_argument_precedence 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' %} From 3556c3371ef923c778b0936d451dea33f8d1a20c Mon Sep 17 00:00:00 2001 From: Julia Boutin Date: Tue, 21 Oct 2025 21:08:35 -0600 Subject: [PATCH 11/19] Raise syntax error on incorrect render identifier type --- lib/liquid/locales/en.yml | 1 + lib/liquid/tags/render.rb | 4 +++- test/integration/tags/snippet_test.rb | 20 ++++++++++++++++++++ 3 files changed, 24 insertions(+), 1 deletion(-) diff --git a/lib/liquid/locales/en.yml b/lib/liquid/locales/en.yml index 6cff46a76..31eec5fd5 100644 --- a/lib/liquid/locales/en.yml +++ b/lib/liquid/locales/en.yml @@ -20,6 +20,7 @@ invalid_delimiter: "'%{tag}' is not a valid delimiter for %{block_name} tags. use %{block_delimiter}" invalid_template_encoding: "Invalid template encoding" render: "Syntax error in tag 'render' - Template name must be a quoted string" + render_invalid_template_name: "Syntax error in tag 'render' - Expected a string or identifier, found %{found}" table_row: "Syntax Error in 'table_row loop' - Valid syntax: table_row [item] in [collection] cols=3" table_row_invalid_attribute: "Invalid attribute '%{attribute}' in tablerow loop. Valid attributes are cols, limit, offset, and range" tag_never_closed: "'%{block_name}' tag was never closed" diff --git a/lib/liquid/tags/render.rb b/lib/liquid/tags/render.rb index 177c8f4dd..a6fd0c481 100644 --- a/lib/liquid/tags/render.rb +++ b/lib/liquid/tags/render.rb @@ -157,8 +157,10 @@ def rigid_parse(markup) def rigid_template_name(p) return p.consume(:string) if p.look(:string) + return p.consume(:id) if p.look(:id) - p.consume(:id) if p.look(:id) + found = p.look(:end_of_string) ? "nothing" : p.read + raise SyntaxError, options[:locale].t("errors.syntax.render_invalid_template_name", found: found) end def strict_parse(markup) diff --git a/test/integration/tags/snippet_test.rb b/test/integration/tags/snippet_test.rb index 8830285ab..18b41ad48 100644 --- a/test/integration/tags/snippet_test.rb +++ b/test/integration/tags/snippet_test.rb @@ -1511,6 +1511,26 @@ def test_render_inline_snippet_inside_loop assert_template_result(expected, template, error_mode: :rigid) end + + def test_render_with_invalid_identifier_type + template = "{% render 123 %}" + + exception = assert_raises(SyntaxError) do + Liquid::Template.parse(template, error_mode: :rigid) + end + + assert_match("Expected a string or identifier, found 123", exception.message) + end + + def test_render_with_no_identifier + template = "{% render %}" + + exception = assert_raises(SyntaxError) do + Liquid::Template.parse(template, error_mode: :rigid) + end + + assert_match("Expected a string or identifier, found nothing", exception.message) + end end class ResourceLimits < SnippetTest From c6c342ab32d7fa366de29dcea77b9736985621bd Mon Sep 17 00:00:00 2001 From: Julia Boutin Date: Wed, 22 Oct 2025 16:21:17 -0600 Subject: [PATCH 12/19] Remove ... syntax references --- lib/liquid/lexer.rb | 10 +- lib/liquid/tags/render.rb | 63 +- test/integration/tags/snippet_test.rb | 1138 ++++++------------------- 3 files changed, 290 insertions(+), 921 deletions(-) diff --git a/lib/liquid/lexer.rb b/lib/liquid/lexer.rb index 94a3d9faf..f1740dbad 100644 --- a/lib/liquid/lexer.rb +++ b/lib/liquid/lexer.rb @@ -17,7 +17,6 @@ class Lexer DASH = [:dash, "-"].freeze DOT = [:dot, "."].freeze DOTDOT = [:dotdot, ".."].freeze - DOTDOTDOT = [:dotdotdot, "..."].freeze DOT_ORD = ".".ord DOUBLE_STRING_LITERAL = /"[^\"]*"/ EOS = [:end_of_string].freeze @@ -114,15 +113,10 @@ def tokenize(ss) if (special = SPECIAL_TABLE[peeked]) ss.scan_byte - # Special case for ".." and "..." + # Special case for ".." if special == DOT && ss.peek_byte == DOT_ORD ss.scan_byte - if ss.peek_byte == DOT_ORD - ss.scan_byte - output << DOTDOTDOT - else - output << DOTDOT - end + output << DOTDOT elsif special == DASH # Special case for negative numbers if (peeked_byte = ss.peek_byte) && NUMBER_TABLE[peeked_byte] diff --git a/lib/liquid/tags/render.rb b/lib/liquid/tags/render.rb index a6fd0c481..55ab7d3f2 100644 --- a/lib/liquid/tags/render.rb +++ b/lib/liquid/tags/render.rb @@ -76,30 +76,7 @@ def render_tag(context, output) inner_context['forloop'] = forloop if forloop @attributes.each do |key, value| - 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 - inner_context[key] = context.evaluate(value) - end + inner_context[key] = context.evaluate(value) end inner_context[context_variable_name] = var unless var.nil? @@ -132,23 +109,11 @@ def rigid_parse(markup) p.consume?(:comma) @attributes = {} - 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 + while p.look(:id) + key = p.consume + p.consume(:colon) + @attributes[key] = safe_parse_expression(p) + p.consume?(:comma) # optional comma end @@ -180,20 +145,8 @@ def lax_parse(markup) @is_for_loop = (with_or_for == FOR) @attributes = {} - 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) - end + markup.scan(TagAttributes) do |key, value| + @attributes[key] = parse_expression(value) end end diff --git a/test/integration/tags/snippet_test.rb b/test/integration/tags/snippet_test.rb index 18b41ad48..1476f9da7 100644 --- a/test/integration/tags/snippet_test.rb +++ b/test/integration/tags/snippet_test.rb @@ -205,6 +205,49 @@ def test_render_inline_snippet_empty_string_when_missing_argument assert_template_result(expected, template) end + def test_render_snippets_as_arguments + template = <<~LIQUID.strip + {% assign color_scheme = 'dark' %} + + {% snippet header %} +
    + {{ message }} +
    + {% endsnippet %} + + {% snippet main %} + {% assign color_scheme = 'auto' %} + +
    + {% render header, message: 'Welcome!' %} +
    + {% endsnippet %} + + {% render main, header: header %} + LIQUID + + expected = <<~OUTPUT + + + + + + + + + +
    + +
    + Welcome! +
    + +
    + OUTPUT + + assert_template_result(expected, template) + end + def test_render_inline_snippet_shouldnt_leak_context template = <<~LIQUID.strip {% snippet input %} @@ -263,7 +306,7 @@ def test_render_multiple_inline_snippets_without_leaking_context assert_template_result(expected, template) end - def test_render_inline_snippet_without_outside_context + def test_render_inline_snippet_ignores_outside_context template = <<~LIQUID.strip {% assign color_scheme = 'dark' %} @@ -291,18 +334,23 @@ def test_render_inline_snippet_without_outside_context assert_template_result(expected, template) end - def test_render_inline_snippet_with_outside_context - template = <<~LIQUID.strip - {% assign color_scheme = 'dark' %} - + def test_render_captured_snippet + template = <<~LIQUID {% snippet header %} -
    +
    {{ message }}
    {% endsnippet %} + {% capture up_header %} + {%- render header, message: 'Welcome!' -%} + {% endcapture %} + + {{ up_header | upcase }} + + {{ header | upcase }} - {% render header, ..., message: 'Welcome!' %} + {{ header }} LIQUID expected = <<~OUTPUT @@ -310,10 +358,14 @@ def test_render_inline_snippet_with_outside_context +
    + WELCOME! +
    + + + SNIPPETDROP -
    - Welcome! -
    + SnippetDrop OUTPUT assert_template_result(expected, template) @@ -332,7 +384,7 @@ def test_inline_snippet_local_scope_takes_precedence {{ color_scheme }} - {% render header, ..., message: 'Welcome!' %} + {% render header, message: 'Welcome!', color_scheme: color_scheme %} {{ color_scheme }} LIQUID @@ -356,814 +408,354 @@ def test_inline_snippet_local_scope_takes_precedence assert_template_result(expected, template) end - def test_render_inline_snippet_with_correct_argument_precedence + def test_render_inline_snippet_forloop template = <<~LIQUID.strip - {% assign color_scheme = 'dark' %} - {% assign message = 'Goodbye!' %} - - {% snippet header %} -
    - {{ message }} -
    + {% snippet item %} +
  • {{ forloop.index }}: {{ item }}
  • {% endsnippet %} - - {% render header, message: 'Welcome!', ... %} + {% assign items = "A,B,C" | split: "," %} + {%- render item for items -%} LIQUID expected = <<~OUTPUT +
  • 1: A
  • +
  • 2: B
  • - - -
    - Goodbye! -
    +
  • 3: C
  • OUTPUT assert_template_result(expected, template) end - def test_render_inline_snippet_with_correct_argument_order + def test_render_inline_snippet_with template = <<~LIQUID.strip - {% assign color_scheme = 'dark' %} - {% assign message = 'Goodbye!' %} - {% snippet header %} -
    - {{ message }} -
    +
    {{ header }}
    {% endsnippet %} - - {% render header, ..., message: 'Welcome!' %} + {% assign product = "Apple" %} + {%- render header with product -%} LIQUID expected = <<~OUTPUT +
    Apple
    + OUTPUT + + assert_template_result(expected, template) + end + + def test_render_inline_snippet_alias + template = <<~LIQUID.strip + {% snippet product_card %} +
    {{ item }}
    + {% endsnippet %} + {% assign featured = "Apple" %} + {%- render product_card with featured as item -%} + LIQUID + expected = <<~OUTPUT -
    - Welcome! -
    +
    Apple
    OUTPUT assert_template_result(expected, template) end + end - def test_render_inline_snippet_with_correct_duplicate_argument_precedence + class RigidMode < SnippetTest + def test_valid_inline_snippet template = <<~LIQUID.strip - {% assign color_scheme = 'dark' %} - {% assign message = 'Goodbye!' %} - - {% snippet header %} -
    - {{ message }} -
    + {% snippet input %} + Hey {% endsnippet %} + LIQUID + expected = '' + + assert_template_result(expected, template, error_mode: :rigid) + end + def test_render_inline_snippet + template = <<~LIQUID.strip + {% snippet hey %} + Hey + {% endsnippet %} - {% render header, message: 'Welcome!', ..., message: 'Hi!' %} + {%- render hey -%} LIQUID expected = <<~OUTPUT + Hey + OUTPUT + assert_template_result(expected, template, error_mode: :rigid) + end + def test_render_inline_snippet_with_variable + template = <<~LIQUID.strip + {% snippet hey %} +

    Today is {{ "hello" | capitalize }}

    + {% endsnippet %} + {%- render hey -%} + LIQUID + expected = <<~OUTPUT - - -
    - Hi! -
    +

    Today is Hello

    OUTPUT - assert_template_result(expected, template) + assert_template_result(expected, template, error_mode: :rigid) end - def test_render_inline_snippet_with_spread_hash + def test_render_multiple_inline_snippets template = <<~LIQUID.strip - {% snippet header %} -
    - {{ word }} {{ number }} -
    + {% snippet input %} + {% endsnippet %} - {% render header, ...details %} - LIQUID + {% snippet banner %} + + Welcome to my store! + + {% endsnippet %} + {%- render input -%} + {%- render banner -%} + LIQUID expected = <<~OUTPUT -
    - potato 5 -
    + + + + Welcome to my store! + OUTPUT - assert_template_result(expected, template, { 'details' => { 'word' => 'potato', 'number' => 5 } }) + 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 test_render_inline_snippet_with_argument + template = <<~LIQUID.strip + {% snippet input %} + + {% endsnippet %} + + {%- render input, type: "text" -%} + LIQUID + expected = <<~OUTPUT - def price - 99 - end + + OUTPUT - def vendor - 'Acme' - end - end + assert_template_result(expected, template, error_mode: :rigid) + end + def test_render_inline_snippet_with_doc_tag template = <<~LIQUID.strip - {% snippet card %} -
    - {{ title }} - ${{ price }} by {{ vendor }} -
    + {% snippet input %} + {% doc %} + @param {string} type - Input type. + {% enddoc %} + + {% endsnippet %} - {% render card, ...product %} + {%- render input, type: "text" -%} LIQUID - expected = <<~OUTPUT -
    - Cool Product - $99 by Acme -
    + OUTPUT - assert_template_result(expected, template, { 'product' => product_drop.new }) + assert_template_result(expected, template, 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 test_render_inline_snippet_with_evaluated_assign + template = <<~LIQUID.strip + {% snippet input %} +

    {{ greeting }}

    + {% endsnippet %} - def price - 99 - end + {%- assign greeting = "hello" | upcase -%} + {%- render input, greeting: greeting -%} + LIQUID + expected = <<~OUTPUT - def vendor - 'Acme' - end - end +

    HELLO

    + OUTPUT + assert_template_result(expected, template, error_mode: :rigid) + end + + def test_render_inline_snippet_with_multiple_arguments template = <<~LIQUID.strip - {% snippet card %} -
    - {{ title }} - ${{ price }} by {{ vendor }} -
    + {% snippet input %} + {% doc %} + @param {string} type - Input type. + @param {string} value - Input value. + {% enddoc %} + + {% endsnippet %} - {% render card, ...product, price: 10 %} + {%- render input, type: "text", value: "Hello" -%} LIQUID - expected = <<~OUTPUT -
    - Cool Product - $10 by Acme -
    + OUTPUT - assert_template_result(expected, template, { 'product' => product_drop.new }) + assert_template_result(expected, template, error_mode: :rigid) end - def test_render_inline_snippet_spread_before_explicit_args + def test_render_inline_snippets_using_same_argument_name template = <<~LIQUID.strip - {% snippet card %} -
    {{ price }}
    + {% snippet input %} + + {% endsnippet %} + + {% snippet inputs %} + {% endsnippet %} - {% render card, ...details, price: 10 %} + {%- render input, type: "text" -%} + {%- render inputs, type: "password", value: "pass" -%} LIQUID expected = <<~OUTPUT -
    10
    + + + OUTPUT - assert_template_result(expected, template, { 'details' => { 'price' => 99 } }) + assert_template_result(expected, template, error_mode: :rigid) end - def test_render_inline_snippet_multiple_spreads - product_drop = Class.new(Liquid::Drop) do - def title - 'Cool Product' - end - end - + def test_render_inline_snippet_empty_string_when_missing_argument template = <<~LIQUID.strip - {% snippet card %} -
    {{ title }} - {{ price }} {{ color }}
    + {% snippet input %} + {% doc %} + @param {string} type - Input type. + @param {string} value - Input value. + {% enddoc %} + + {% endsnippet %} - {% render card, ...defaults, ...product %} + {%- render input, type: "text" -%} LIQUID - expected = <<~OUTPUT -
    Cool Product - 10
    + OUTPUT - assert_template_result( - expected, - template, - { - 'defaults' => { 'title' => 'Default', 'price' => 10 }, - 'product' => product_drop.new, - }, - ) + assert_template_result(expected, template, error_mode: :rigid) end - def test_render_captured_snippet - template = <<~LIQUID + def test_render_snippets_as_arguments + template = <<~LIQUID.strip {% assign color_scheme = 'dark' %} {% snippet header %} -
    - {{ message }} -
    - {% endsnippet %} - - {% capture up_header %} - {%- render header, ..., message: 'Welcome!' -%} - {% endcapture %} - - {{ up_header | upcase }} - - {{ header | upcase }} - - {{ header }} - LIQUID - expected = <<~OUTPUT - - - - - - - -
    - WELCOME! -
    - - - SNIPPETDROP - - SnippetDrop - OUTPUT - - assert_template_result(expected, template) - end - - def test_render_snippets_as_arguments - template = <<~LIQUID.strip - {% assign color_scheme = 'dark' %} - - {% snippet header %} -
    +
    {{ message }} -
    - {% endsnippet %} - - {% snippet main %} - {% assign color_scheme = 'auto' %} - -
    - {% render header, ..., message: 'Welcome!' %} -
    - {% endsnippet %} - - {% render main, header: header %} - LIQUID - - expected = <<~OUTPUT - - - - - - - - - -
    - -
    - Welcome! -
    - -
    - OUTPUT - - assert_template_result(expected, template) - end - - def test_render_inline_snippet_forloop - template = <<~LIQUID.strip - {% snippet item %} -
  • {{ forloop.index }}: {{ item }}
  • - {% endsnippet %} - - {% assign items = "A,B,C" | split: "," %} - {%- render item for items -%} - LIQUID - expected = <<~OUTPUT - - - -
  • 1: A
  • - -
  • 2: B
  • - -
  • 3: C
  • - OUTPUT - - assert_template_result(expected, template) - end - - def test_render_inline_snippet_with - template = <<~LIQUID.strip - {% snippet header %} -
    {{ header }}
    - {% endsnippet %} - - {% assign product = "Apple" %} - {%- render header with product -%} - LIQUID - expected = <<~OUTPUT - - - -
    Apple
    - OUTPUT - - assert_template_result(expected, template) - end - - def test_render_inline_snippet_alias - template = <<~LIQUID.strip - {% snippet product_card %} -
    {{ item }}
    - {% endsnippet %} - - {% assign featured = "Apple" %} - {%- render product_card with featured as item -%} - LIQUID - expected = <<~OUTPUT - - - -
    Apple
    - OUTPUT - - assert_template_result(expected, template) - end - - def test_render_inline_snippet_inside_loop - template = <<~LIQUID.strip - {% assign color_scheme = 'dark' %} - {% assign array = '1,2,3' | split: ',' %} - - {% for i in array %} - {% snippet header %} -
    - {{ message }} {{ i }} -
    - {% endsnippet %} - {% endfor %} - - {% render header, ..., message: '👉' %} - LIQUID - expected = <<~OUTPUT - - - - - - -
    - 👉#{" "} -
    - OUTPUT - - assert_template_result(expected, template) - end - end - - class RigidMode < SnippetTest - def test_valid_inline_snippet - template = <<~LIQUID.strip - {% snippet input %} - Hey - {% endsnippet %} - LIQUID - expected = '' - - assert_template_result(expected, template, error_mode: :rigid) - end - - def test_render_inline_snippet - template = <<~LIQUID.strip - {% snippet hey %} - Hey - {% endsnippet %} - - {%- render hey -%} - LIQUID - expected = <<~OUTPUT - - Hey - OUTPUT - - assert_template_result(expected, template, error_mode: :rigid) - end - - def test_render_inline_snippet_with_variable - template = <<~LIQUID.strip - {% snippet hey %} -

    Today is {{ "hello" | capitalize }}

    - {% endsnippet %} - - {%- render hey -%} - LIQUID - expected = <<~OUTPUT - -

    Today is Hello

    - OUTPUT - - assert_template_result(expected, template, error_mode: :rigid) - end - - def test_render_multiple_inline_snippets - template = <<~LIQUID.strip - {% snippet input %} - - {% endsnippet %} - - {% snippet banner %} - - Welcome to my store! - - {% endsnippet %} - - {%- render input -%} - {%- render banner -%} - LIQUID - expected = <<~OUTPUT - - - - - - - Welcome to my store! - - OUTPUT - - assert_template_result(expected, template, error_mode: :rigid) - end - - def test_render_inline_snippet_with_argument - template = <<~LIQUID.strip - {% snippet input %} - - {% endsnippet %} - - {%- render input, type: "text" -%} - LIQUID - expected = <<~OUTPUT - - - OUTPUT - - assert_template_result(expected, template, error_mode: :rigid) - end - - def test_render_inline_snippet_with_doc_tag - template = <<~LIQUID.strip - {% snippet input %} - {% doc %} - @param {string} type - Input type. - {% enddoc %} - - - {% endsnippet %} - - {%- render input, type: "text" -%} - LIQUID - expected = <<~OUTPUT - - - - - OUTPUT - - assert_template_result(expected, template, error_mode: :rigid) - end - - def test_render_inline_snippet_with_evaluated_assign - template = <<~LIQUID.strip - {% snippet input %} -

    {{ greeting }}

    - {% endsnippet %} - - {%- assign greeting = "hello" | upcase -%} - {%- render input, greeting: greeting -%} - LIQUID - expected = <<~OUTPUT - -

    HELLO

    - OUTPUT - - assert_template_result(expected, template, error_mode: :rigid) - end - - def test_render_inline_snippet_with_multiple_arguments - template = <<~LIQUID.strip - {% snippet input %} - {% doc %} - @param {string} type - Input type. - @param {string} value - Input value. - {% enddoc %} - - - {% endsnippet %} - - {%- render input, type: "text", value: "Hello" -%} - LIQUID - expected = <<~OUTPUT - - - - - OUTPUT - - assert_template_result(expected, template, error_mode: :rigid) - end - - def test_render_inline_snippets_using_same_argument_name - template = <<~LIQUID.strip - {% snippet input %} - - {% endsnippet %} - - {% snippet inputs %} - - {% endsnippet %} - - {%- render input, type: "text" -%} - {%- render inputs, type: "password", value: "pass" -%} - LIQUID - - expected = <<~OUTPUT - - - - - - - OUTPUT - - assert_template_result(expected, template, error_mode: :rigid) - end - - def test_render_inline_snippet_empty_string_when_missing_argument - template = <<~LIQUID.strip - {% snippet input %} - {% doc %} - @param {string} type - Input type. - @param {string} value - Input value. - {% enddoc %} - - - {% endsnippet %} - - {%- render input, type: "text" -%} - LIQUID - expected = <<~OUTPUT - - - - - OUTPUT - - assert_template_result(expected, template, error_mode: :rigid) - end - - def test_render_inline_snippet_shouldnt_leak_context - template = <<~LIQUID.strip - {% snippet input %} - {% doc %} - @param {string} type - Input type. - @param {string} value - Input value. - {% enddoc %} - - - {% endsnippet %} - - {%- render input, type: "text", value: "Hello" -%} - - {{ type }} - {{ value }} - LIQUID - expected = <<~OUTPUT - - - - - - OUTPUT - - assert_template_result(expected, template, error_mode: :rigid) - end - - def test_render_multiple_inline_snippets_without_leaking_context - template = <<~LIQUID.strip - {% snippet input %} - {% doc %} - @param {string} type - Input type. - {% enddoc %} - - - {% endsnippet %} - - {% snippet no_leak %} - - {% endsnippet %} - - {%- render input, type: "text" -%} - {%- render no_leak -%} - LIQUID - expected = <<~OUTPUT - - - - - - - - - OUTPUT - - assert_template_result(expected, template, error_mode: :rigid) - end - - def test_render_inline_snippet_without_outside_context - template = <<~LIQUID.strip - {% assign color_scheme = 'dark' %} - - {% snippet header %} -
    - {{ message }} -
    +
    {% endsnippet %} + {% snippet main %} + {% assign color_scheme = 'auto' %} +
    {% render header, message: 'Welcome!' %} - LIQUID - expected = <<~OUTPUT - - - - - - -
    - Welcome! -
    - OUTPUT - - assert_template_result(expected, template, error_mode: :rigid) - end - - def test_render_inline_snippet_with_outside_context - template = <<~LIQUID.strip - {% assign color_scheme = 'dark' %} - - {% snippet header %} -
    - {{ message }}
    {% endsnippet %} - - {% render header, ..., message: 'Welcome!' %} + {% render main, header: header %} LIQUID - expected = <<~OUTPUT - - - + expected = <<~OUTPUT -
    - Welcome! -
    - OUTPUT - - assert_template_result(expected, template, error_mode: :rigid) - end - - def test_inline_snippet_local_scope_takes_precedence - template = <<~LIQUID - {% assign color_scheme = 'dark' %} - - {% snippet header %} - {% assign color_scheme = 'light' %} -
    - {{ message }} -
    - {% endsnippet %} - - {{ color_scheme }} - {% render header, ..., message: 'Welcome!' %} - {{ color_scheme }} - LIQUID - expected = <<~OUTPUT - dark +
    +
    + Welcome! +
    -
    - Welcome!
    - - - dark OUTPUT assert_template_result(expected, template, error_mode: :rigid) end - def test_render_inline_snippet_with_correct_argument_precedence + def test_render_inline_snippet_shouldnt_leak_context template = <<~LIQUID.strip - {% assign color_scheme = 'dark' %} - {% assign message = 'Goodbye!' %} + {% snippet input %} + {% doc %} + @param {string} type - Input type. + @param {string} value - Input value. + {% enddoc %} - {% snippet header %} -
    - {{ message }} -
    + {% endsnippet %} + {%- render input, type: "text", value: "Hello" -%} - {% render header, message: 'Welcome!', ... %} + {{ type }} + {{ value }} LIQUID expected = <<~OUTPUT + - - - -
    - Goodbye! -
    OUTPUT assert_template_result(expected, template, error_mode: :rigid) end - def test_render_inline_snippet_with_correct_argument_order + def test_render_multiple_inline_snippets_without_leaking_context template = <<~LIQUID.strip - {% assign color_scheme = 'dark' %} - {% assign message = 'Goodbye!' %} + {% snippet input %} + {% doc %} + @param {string} type - Input type. + {% enddoc %} - {% snippet header %} -
    - {{ message }} -
    + {% endsnippet %} + {% snippet no_leak %} + + {% endsnippet %} - {% render header, ..., message: 'Welcome!' %} + {%- render input, type: "text" -%} + {%- render no_leak -%} LIQUID expected = <<~OUTPUT @@ -1171,20 +763,17 @@ def test_render_inline_snippet_with_correct_argument_order + - -
    - Welcome! -
    + OUTPUT assert_template_result(expected, template, error_mode: :rigid) end - def test_render_inline_snippet_with_correct_duplicate_argument_precedence + def test_render_inline_snippet_ignores_outside_context template = <<~LIQUID.strip {% assign color_scheme = 'dark' %} - {% assign message = 'Goodbye!' %} {% snippet header %}
    @@ -1193,7 +782,7 @@ def test_render_inline_snippet_with_correct_duplicate_argument_precedence {% endsnippet %} - {% render header, message: 'Welcome!', ..., message: 'Hi!' %} + {% render header, message: 'Welcome!' %} LIQUID expected = <<~OUTPUT @@ -1202,220 +791,46 @@ def test_render_inline_snippet_with_correct_duplicate_argument_precedence - -
    - Hi! +
    + Welcome!
    OUTPUT 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 + def test_inline_snippet_local_scope_takes_precedence template = <<~LIQUID {% assign color_scheme = 'dark' %} {% snippet header %} + {% assign color_scheme = 'light' %}
    {{ message }}
    {% endsnippet %} - {% capture up_header %} - {%- render header, ..., message: 'Welcome!' -%} - {% endcapture %} - - {{ up_header | upcase }} + {{ color_scheme }} - {{ header | upcase }} + {% render header, message: 'Welcome!', color_scheme: color_scheme %} - {{ header }} + {{ color_scheme }} LIQUID expected = <<~OUTPUT + dark -
    - WELCOME! -
    - - - SNIPPETDROP - - SnippetDrop - OUTPUT - - assert_template_result(expected, template, error_mode: :rigid) - end - - def test_render_snippets_as_arguments - template = <<~LIQUID.strip - {% assign color_scheme = 'dark' %} - - {% snippet header %} -
    - {{ message }} -
    - {% endsnippet %} - - {% snippet main %} - {% assign color_scheme = 'auto' %} - -
    - {% render header, ..., message: 'Welcome!' %} +
    + Welcome!
    - {% endsnippet %} - - {% render main, header: header %} - LIQUID - - expected = <<~OUTPUT - - - - - - - -
    - -
    - Welcome! -
    - -
    + dark OUTPUT assert_template_result(expected, template, error_mode: :rigid) @@ -1482,20 +897,23 @@ def test_render_inline_snippet_alias assert_template_result(expected, template, error_mode: :rigid) end - def test_render_inline_snippet_inside_loop - template = <<~LIQUID.strip - {% assign color_scheme = 'dark' %} - {% assign array = '1,2,3' | split: ',' %} - - {% for i in array %} + def test_render_captured_snippet + template = <<~LIQUID {% snippet header %} -
    - {{ message }} {{ i }} +
    + {{ message }}
    {% endsnippet %} - {% endfor %} - {% render header, ..., message: '👉' %} + {% capture up_header %} + {%- render header, message: 'Welcome!' -%} + {% endcapture %} + + {{ up_header | upcase }} + + {{ header | upcase }} + + {{ header }} LIQUID expected = <<~OUTPUT @@ -1503,10 +921,14 @@ def test_render_inline_snippet_inside_loop +
    + WELCOME! +
    -
    - 👉#{" "} -
    + + SNIPPETDROP + + SnippetDrop OUTPUT assert_template_result(expected, template, error_mode: :rigid) From 1069b944a0cd482e5897a6d9160c6da0017f59be Mon Sep 17 00:00:00 2001 From: Julia Boutin Date: Thu, 23 Oct 2025 13:06:49 -0600 Subject: [PATCH 13/19] Allow render tag to recognize drops that respond to to_partial --- example/server/templates/index.liquid | 5 ++-- lib/liquid/locales/en.yml | 1 + lib/liquid/snippet_drop.rb | 9 ++++-- lib/liquid/tags/render.rb | 37 +++++++++--------------- lib/liquid/tags/snippet.rb | 2 +- test/integration/tags/render_tag_test.rb | 12 ++++++-- 6 files changed, 35 insertions(+), 31 deletions(-) diff --git a/example/server/templates/index.liquid b/example/server/templates/index.liquid index e18cc306d..4872aa845 100644 --- a/example/server/templates/index.liquid +++ b/example/server/templates/index.liquid @@ -1,5 +1,6 @@

    Hello world!

    -

    It is {{ date }}

    +

    It is {{date}}

    -

    Check out the Products screen

    + +

    Check out the Products screen

    diff --git a/lib/liquid/locales/en.yml b/lib/liquid/locales/en.yml index 31eec5fd5..7a9fb71e4 100644 --- a/lib/liquid/locales/en.yml +++ b/lib/liquid/locales/en.yml @@ -31,5 +31,6 @@ variable_termination: "Variable '%{token}' was not properly terminated with regexp: %{tag_end}" argument: include: "Argument error in tag 'include' - Illegal template name" + render: "Argument error in tag 'render' - Dynamically chosen templates are not allowed" disabled: tag: "usage is not allowed in this context" diff --git a/lib/liquid/snippet_drop.rb b/lib/liquid/snippet_drop.rb index fb48d068d..116488268 100644 --- a/lib/liquid/snippet_drop.rb +++ b/lib/liquid/snippet_drop.rb @@ -2,11 +2,16 @@ module Liquid class SnippetDrop < Drop - attr_reader :body + attr_reader :body, :name - def initialize(body) + def initialize(body, name) super() @body = body + @name = name + end + + def to_partial + @body end def to_s diff --git a/lib/liquid/tags/render.rb b/lib/liquid/tags/render.rb index 55ab7d3f2..c337f4dac 100644 --- a/lib/liquid/tags/render.rb +++ b/lib/liquid/tags/render.rb @@ -47,40 +47,30 @@ def render_to_output_buffer(context, output) end def render_tag(context, output) - template_name = @template_name_expr - is_inline = template_name.is_a?(VariableLookup) - is_file = template_name.is_a?(String) - - if is_inline - template_name = template_name.name - snippet_drop = context[template_name] - raise ::ArgumentError unless snippet_drop.is_a?(Liquid::SnippetDrop) - - partial = snippet_drop.body + template = context.evaluate(@template_name_expr) + + if template.respond_to?(:to_partial) + partial = template.to_partial + template_name = template.name + elsif @template_name_expr.is_a?(String) + partial = PartialCache.load(template, context: context, parse_context: parse_context) + template_name = partial.name else - raise ::ArgumentError unless is_file - - partial = PartialCache.load(template_name, context: context, parse_context: parse_context) + raise ::ArgumentError, parse_context.locale.t("errors.argument.render") end context_variable_name = @alias_name || template_name.split('/').last render_partial_func = ->(var, forloop) { - inner_context = context.new_isolated_subcontext - - if is_file - inner_context.template_name = partial.name - inner_context.partial = true - end - - inner_context['forloop'] = forloop if forloop + inner_context = context.new_isolated_subcontext + inner_context.template_name = template_name + inner_context.partial = true + inner_context['forloop'] = forloop if forloop @attributes.each do |key, value| inner_context[key] = context.evaluate(value) end - inner_context[context_variable_name] = var unless var.nil? - partial.render_to_output_buffer(inner_context, output) forloop&.send(:increment!) } @@ -113,7 +103,6 @@ def rigid_parse(markup) key = p.consume p.consume(:colon) @attributes[key] = safe_parse_expression(p) - p.consume?(:comma) # optional comma end diff --git a/lib/liquid/tags/snippet.rb b/lib/liquid/tags/snippet.rb index cf6ef7569..2d49cdf68 100644 --- a/lib/liquid/tags/snippet.rb +++ b/lib/liquid/tags/snippet.rb @@ -25,7 +25,7 @@ def initialize(tag_name, markup, options) end def render_to_output_buffer(context, output) - snippet_drop = SnippetDrop.new(@body) + snippet_drop = SnippetDrop.new(@body, @to) context.scopes.last[@to] = snippet_drop snippet_size = @body.nodelist.sum { |node| node.to_s.bytesize } diff --git a/test/integration/tags/render_tag_test.rb b/test/integration/tags/render_tag_test.rb index e4ce0c294..4dedaaaea 100644 --- a/test/integration/tags/render_tag_test.rb +++ b/test/integration/tags/render_tag_test.rb @@ -101,10 +101,11 @@ def test_sub_contexts_count_towards_the_same_recursion_limit end end - def test_dynamically_choosen_templates_are_not_allowed - assert_raises(::ArgumentError) do + def test_dynamically_chosen_templates_are_not_allowed + error = assert_raises(::ArgumentError) do Template.parse('{% assign name = "snippet" %}{% render name %}').render! end + assert_equal("Argument error in tag 'render' - Dynamically chosen templates are not allowed", error.message) end def test_rigid_parsing_errors @@ -296,6 +297,13 @@ def test_render_tag_with_drop ) end + def test_render_tag_with_snippet_drop + assert_template_result( + "Hello from snippet", + "{% snippet my_snippet %}Hello from snippet{% endsnippet %}{% render my_snippet %}", + ) + end + def test_render_tag_renders_error_with_template_name assert_template_result( 'Liquid error (foo line 1): standard error', From de23ed82d58316fc429974375e5e2e19a3b4b150 Mon Sep 17 00:00:00 2001 From: Julia Boutin Date: Thu, 23 Oct 2025 13:09:39 -0600 Subject: [PATCH 14/19] Extract snippet resource scoring logic into assign_score_of --- lib/liquid/tags/snippet.rb | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/lib/liquid/tags/snippet.rb b/lib/liquid/tags/snippet.rb index 2d49cdf68..13eb531f3 100644 --- a/lib/liquid/tags/snippet.rb +++ b/lib/liquid/tags/snippet.rb @@ -27,15 +27,18 @@ def initialize(tag_name, markup, options) def render_to_output_buffer(context, output) snippet_drop = SnippetDrop.new(@body, @to) context.scopes.last[@to] = snippet_drop - - snippet_size = @body.nodelist.sum { |node| node.to_s.bytesize } - context.resource_limits.increment_assign_score(snippet_size) - + context.resource_limits.increment_assign_score(assign_score_of(snippet_drop)) output end def blank? true end + + private + + def assign_score_of(snippet_drop) + snippet_drop.body.nodelist.sum { |node| node.to_s.bytesize } + end end end From 47288ccca04444e9bef8427cb2a5b06dc2c65dbc Mon Sep 17 00:00:00 2001 From: Julia Boutin Date: Fri, 24 Oct 2025 10:39:13 -0600 Subject: [PATCH 15/19] Add liquid_public_docs yard tag to snippet tag --- lib/liquid/tags/snippet.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/liquid/tags/snippet.rb b/lib/liquid/tags/snippet.rb index 13eb531f3..c53da9f13 100644 --- a/lib/liquid/tags/snippet.rb +++ b/lib/liquid/tags/snippet.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true module Liquid + # @liquid_public_docs # @liquid_type tag # @liquid_category variable # @liquid_name snippet @@ -12,7 +13,6 @@ module Liquid # {% snippet input %} # value # {% endsnippet %} - class Snippet < Block def initialize(tag_name, markup, options) super From d109bc92429c99d64961f7d59fce4972ffc9a38d Mon Sep 17 00:00:00 2001 From: Julia Boutin Date: Mon, 27 Oct 2025 12:41:59 -0600 Subject: [PATCH 16/19] Remove unneeded read method --- lib/liquid/parser.rb | 8 -------- lib/liquid/tags/render.rb | 2 +- test/integration/tags/snippet_test.rb | 10 ++++++++++ 3 files changed, 11 insertions(+), 9 deletions(-) diff --git a/lib/liquid/parser.rb b/lib/liquid/parser.rb index 93cda9083..645dfa3a1 100644 --- a/lib/liquid/parser.rb +++ b/lib/liquid/parser.rb @@ -12,14 +12,6 @@ def jump(point) @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 c337f4dac..cabbfe2e0 100644 --- a/lib/liquid/tags/render.rb +++ b/lib/liquid/tags/render.rb @@ -113,7 +113,7 @@ def rigid_template_name(p) return p.consume(:string) if p.look(:string) return p.consume(:id) if p.look(:id) - found = p.look(:end_of_string) ? "nothing" : p.read + found = p.consume || "nothing" raise SyntaxError, options[:locale].t("errors.syntax.render_invalid_template_name", found: found) end diff --git a/test/integration/tags/snippet_test.rb b/test/integration/tags/snippet_test.rb index 1476f9da7..7a0383ad2 100644 --- a/test/integration/tags/snippet_test.rb +++ b/test/integration/tags/snippet_test.rb @@ -953,6 +953,16 @@ def test_render_with_no_identifier assert_match("Expected a string or identifier, found nothing", exception.message) end + + def test_render_with_invalid_identifier + template = "{% render 123 %}" + + exception = assert_raises(SyntaxError) do + Liquid::Template.parse(template, error_mode: :rigid) + end + + assert_match("Expected a string or identifier, found 123", exception.message) + end end class ResourceLimits < SnippetTest From 99b0b388062e1ba9b6f2a0c3f1decfd0701b2638 Mon Sep 17 00:00:00 2001 From: Julia Boutin Date: Tue, 28 Oct 2025 11:30:24 -0600 Subject: [PATCH 17/19] Raise error on invalid snippet name --- lib/liquid/tags/snippet.rb | 3 ++- test/integration/tags/snippet_test.rb | 24 ++++++++++++++++++++---- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/lib/liquid/tags/snippet.rb b/lib/liquid/tags/snippet.rb index c53da9f13..7853adc49 100644 --- a/lib/liquid/tags/snippet.rb +++ b/lib/liquid/tags/snippet.rb @@ -10,7 +10,7 @@ module Liquid # @liquid_description # You can create inline snippets to make your Liquid code more modular. # @liquid_syntax - # {% snippet input %} + # {% snippet snippet_name %} # value # {% endsnippet %} class Snippet < Block @@ -19,6 +19,7 @@ def initialize(tag_name, markup, options) p = @parse_context.new_parser(markup) if p.look(:id) @to = p.consume(:id) + p.consume(:end_of_string) else raise SyntaxError, options[:locale].t("errors.syntax.snippet") end diff --git a/test/integration/tags/snippet_test.rb b/test/integration/tags/snippet_test.rb index 7a0383ad2..37fa67ecc 100644 --- a/test/integration/tags/snippet_test.rb +++ b/test/integration/tags/snippet_test.rb @@ -468,6 +468,18 @@ def test_render_inline_snippet_alias assert_template_result(expected, template) end + + def test_snippet_with_invalid_identifier + template = <<~LIQUID + {% snippet header foo bar %} + Invalid + {% endsnippet %} + LIQUID + + exception = assert_raises(SyntaxError) { Liquid::Template.parse(template) } + + assert_match("Expected end_of_string but found id", exception.message) + end end class RigidMode < SnippetTest @@ -934,7 +946,7 @@ def test_render_captured_snippet assert_template_result(expected, template, error_mode: :rigid) end - def test_render_with_invalid_identifier_type + def test_render_with_invalid_identifier template = "{% render 123 %}" exception = assert_raises(SyntaxError) do @@ -954,14 +966,18 @@ def test_render_with_no_identifier assert_match("Expected a string or identifier, found nothing", exception.message) end - def test_render_with_invalid_identifier - template = "{% render 123 %}" + def test_snippet_with_invalid_identifier + template = <<~LIQUID + {% snippet header foo bar %} + Invalid + {% endsnippet %} + LIQUID exception = assert_raises(SyntaxError) do Liquid::Template.parse(template, error_mode: :rigid) end - assert_match("Expected a string or identifier, found 123", exception.message) + assert_match("Expected end_of_string but found id", exception.message) end end From eab457f8ca585640f25b7e3df970769e08b103ae Mon Sep 17 00:00:00 2001 From: Julia Boutin Date: Wed, 29 Oct 2025 11:14:15 -0600 Subject: [PATCH 18/19] Missing inline snippets should display same error as filebased --- lib/liquid/locales/en.yml | 2 + lib/liquid/snippet_drop.rb | 5 +- lib/liquid/tags/render.rb | 8 +- lib/liquid/tags/snippet.rb | 2 +- test/integration/tags/render_tag_test.rb | 7 -- test/integration/tags/snippet_test.rb | 96 ++++++++++++++++++++++++ 6 files changed, 106 insertions(+), 14 deletions(-) diff --git a/lib/liquid/locales/en.yml b/lib/liquid/locales/en.yml index 7a9fb71e4..53421f4c1 100644 --- a/lib/liquid/locales/en.yml +++ b/lib/liquid/locales/en.yml @@ -34,3 +34,5 @@ render: "Argument error in tag 'render' - Dynamically chosen templates are not allowed" disabled: tag: "usage is not allowed in this context" + file_system: + includes: "This liquid context does not allow includes" diff --git a/lib/liquid/snippet_drop.rb b/lib/liquid/snippet_drop.rb index 116488268..98686999c 100644 --- a/lib/liquid/snippet_drop.rb +++ b/lib/liquid/snippet_drop.rb @@ -2,12 +2,13 @@ module Liquid class SnippetDrop < Drop - attr_reader :body, :name + attr_reader :body, :name, :filename - def initialize(body, name) + def initialize(body, name, filename) super() @body = body @name = name + @filename = filename end def to_partial diff --git a/lib/liquid/tags/render.rb b/lib/liquid/tags/render.rb index cabbfe2e0..7f9672820 100644 --- a/lib/liquid/tags/render.rb +++ b/lib/liquid/tags/render.rb @@ -51,16 +51,16 @@ def render_tag(context, output) if template.respond_to?(:to_partial) partial = template.to_partial - template_name = template.name + template_name = template.filename + context_variable_name = @alias_name || template.name elsif @template_name_expr.is_a?(String) partial = PartialCache.load(template, context: context, parse_context: parse_context) template_name = partial.name + context_variable_name = @alias_name || template_name.split('/').last else - raise ::ArgumentError, parse_context.locale.t("errors.argument.render") + raise Liquid::ArgumentError, parse_context.locale.t("errors.file_system.includes") end - context_variable_name = @alias_name || template_name.split('/').last - render_partial_func = ->(var, forloop) { inner_context = context.new_isolated_subcontext inner_context.template_name = template_name diff --git a/lib/liquid/tags/snippet.rb b/lib/liquid/tags/snippet.rb index 7853adc49..87e2a0ded 100644 --- a/lib/liquid/tags/snippet.rb +++ b/lib/liquid/tags/snippet.rb @@ -26,7 +26,7 @@ def initialize(tag_name, markup, options) end def render_to_output_buffer(context, output) - snippet_drop = SnippetDrop.new(@body, @to) + snippet_drop = SnippetDrop.new(@body, @to, context.template_name) context.scopes.last[@to] = snippet_drop context.resource_limits.increment_assign_score(assign_score_of(snippet_drop)) output diff --git a/test/integration/tags/render_tag_test.rb b/test/integration/tags/render_tag_test.rb index 4dedaaaea..6fe19abb4 100644 --- a/test/integration/tags/render_tag_test.rb +++ b/test/integration/tags/render_tag_test.rb @@ -101,13 +101,6 @@ def test_sub_contexts_count_towards_the_same_recursion_limit end end - def test_dynamically_chosen_templates_are_not_allowed - error = assert_raises(::ArgumentError) do - Template.parse('{% assign name = "snippet" %}{% render name %}').render! - end - assert_equal("Argument error in tag 'render' - Dynamically chosen templates are not allowed", error.message) - end - def test_rigid_parsing_errors with_error_modes(:lax, :strict) do assert_template_result( diff --git a/test/integration/tags/snippet_test.rb b/test/integration/tags/snippet_test.rb index 37fa67ecc..571890baf 100644 --- a/test/integration/tags/snippet_test.rb +++ b/test/integration/tags/snippet_test.rb @@ -480,6 +480,54 @@ def test_snippet_with_invalid_identifier assert_match("Expected end_of_string but found id", exception.message) end + + def test_render_with_non_existent_tag + template = Liquid::Template.parse(<<~LIQUID.chomp, line_numbers: true) + {% snippet foo %} + {% render non_existent %} + {% endsnippet %} + + {% render foo %} + LIQUID + + expected = <<~TEXT + + + + Liquid error (index line 2): This liquid context does not allow includes + TEXT + template.name = "index" + + assert_equal(expected, template.render('errors' => ErrorDrop.new)) + end + + def test_render_handles_errors + template = Liquid::Template.parse(<<~LIQUID.chomp, line_numbers: true) + {% snippet foo %} + {% render non_existent %} will raise an error. + + Bla bla test. + + This is an argument error: {{ 'test' | slice: 'not a number' }} + {% endsnippet %} + + {% render foo %} + LIQUID + + expected = <<~TEXT + + + + Liquid error (index line 2): This liquid context does not allow includes will raise an error. + + Bla bla test. + + This is an argument error: Liquid error (index line 6): invalid integer + TEXT + template.name = "index" + + assert_equal(expected, template.render('errors' => ErrorDrop.new)) + end end class RigidMode < SnippetTest @@ -956,6 +1004,54 @@ def test_render_with_invalid_identifier assert_match("Expected a string or identifier, found 123", exception.message) end + def test_render_with_non_existent_tag + template = Liquid::Template.parse(<<~LIQUID.chomp, line_numbers: true, error_mode: :rigid) + {% snippet foo %} + {% render non_existent %} + {% endsnippet %} + + {% render foo %} + LIQUID + + expected = <<~TEXT + + + + Liquid error (index line 2): This liquid context does not allow includes + TEXT + template.name = "index" + + assert_equal(expected, template.render('errors' => ErrorDrop.new)) + end + + def test_render_handles_errors + template = Liquid::Template.parse(<<~LIQUID.chomp, line_numbers: true, error_mode: :rigid) + {% snippet foo %} + {% render non_existent %} will raise an error. + + Bla bla test. + + This is an argument error: {{ 'test' | slice: 'not a number' }} + {% endsnippet %} + + {% render foo %} + LIQUID + + expected = <<~TEXT + + + + Liquid error (index line 2): This liquid context does not allow includes will raise an error. + + Bla bla test. + + This is an argument error: Liquid error (index line 6): invalid integer + TEXT + template.name = "index" + + assert_equal(expected, template.render('errors' => ErrorDrop.new)) + end + def test_render_with_no_identifier template = "{% render %}" From c4a6987a307be14a6714375b64c5878bdb246608 Mon Sep 17 00:00:00 2001 From: Guilherme Carreiro Date: Thu, 30 Oct 2025 11:56:30 +0100 Subject: [PATCH 19/19] Update error handling for keeping backward-compatibility on error messages in the render tag --- lib/liquid/locales/en.yml | 2 -- lib/liquid/tags/render.rb | 2 +- test/integration/tags/snippet_test.rb | 8 ++++---- 3 files changed, 5 insertions(+), 7 deletions(-) diff --git a/lib/liquid/locales/en.yml b/lib/liquid/locales/en.yml index 53421f4c1..7a9fb71e4 100644 --- a/lib/liquid/locales/en.yml +++ b/lib/liquid/locales/en.yml @@ -34,5 +34,3 @@ render: "Argument error in tag 'render' - Dynamically chosen templates are not allowed" disabled: tag: "usage is not allowed in this context" - file_system: - includes: "This liquid context does not allow includes" diff --git a/lib/liquid/tags/render.rb b/lib/liquid/tags/render.rb index 7f9672820..dec725ed4 100644 --- a/lib/liquid/tags/render.rb +++ b/lib/liquid/tags/render.rb @@ -58,7 +58,7 @@ def render_tag(context, output) template_name = partial.name context_variable_name = @alias_name || template_name.split('/').last else - raise Liquid::ArgumentError, parse_context.locale.t("errors.file_system.includes") + raise ::ArgumentError end render_partial_func = ->(var, forloop) { diff --git a/test/integration/tags/snippet_test.rb b/test/integration/tags/snippet_test.rb index 571890baf..6d1cb35d7 100644 --- a/test/integration/tags/snippet_test.rb +++ b/test/integration/tags/snippet_test.rb @@ -494,7 +494,7 @@ def test_render_with_non_existent_tag - Liquid error (index line 2): This liquid context does not allow includes + Liquid error (index line 2): internal TEXT template.name = "index" @@ -518,7 +518,7 @@ def test_render_handles_errors - Liquid error (index line 2): This liquid context does not allow includes will raise an error. + Liquid error (index line 2): internal will raise an error. Bla bla test. @@ -1017,7 +1017,7 @@ def test_render_with_non_existent_tag - Liquid error (index line 2): This liquid context does not allow includes + Liquid error (index line 2): internal TEXT template.name = "index" @@ -1041,7 +1041,7 @@ def test_render_handles_errors - Liquid error (index line 2): This liquid context does not allow includes will raise an error. + Liquid error (index line 2): internal will raise an error. Bla bla test.