diff --git a/Gemfile b/Gemfile index a404c0d4..fc327c92 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 00000000..eff33ff5 --- /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 00000000..68e214da --- /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 4872aa84..52905508 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 b99d490c..b2196686 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 dff7553f..3ec5e7cf 100644 --- a/lib/liquid/tags.rb +++ b/lib/liquid/tags.rb @@ -20,6 +20,7 @@ require_relative "tags/raw" 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 Liquid '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 4d29e420..5bff6073 100644 --- a/lib/liquid/tags/render.rb +++ b/lib/liquid/tags/render.rb @@ -51,6 +51,24 @@ module Liquid 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 00000000..72c6c282 --- /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 00000000..fd1cbcdb --- /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