Introduce new inline snippets tag

Inline snippets will reduce code duplication and
improve the developer experience, eliminating the
need for one-off snippet files
This commit is contained in:
Josh Faigan
2025-10-27 10:11:21 -06:00
committed by Julia Boutin
parent 1f58216f48
commit 8c6149782d
9 changed files with 470 additions and 3 deletions
+4
View File
@@ -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
+77
View File
@@ -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
+59
View File
@@ -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
+68 -3
View File
@@ -1,6 +1,71 @@
<p>Hello world!</p>
<!DOCTYPE html>
<html lang="en">
<p>It is {{date}}</p>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Simple Code Editor</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.5/codemirror.min.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.5/theme/dracula.min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.5/codemirror.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.5/mode/xml/xml.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.5/mode/htmlmixed/htmlmixed.min.js"></script>
<style>
.liquid, .CodeMirror {
position: fixed;
height: 100vh;
width: 50vw;
top: 0;
font-size: 24px;
}
.liquid {
left: 0;
}
.CodeMirror {
left: 50%;
}
.CodeMirror-hscrollbar {
overflow: hidden;
}
</style>
</head>
<body>
<div class="liquid">
{% snippet "main" %}
<p>Check out the <a href="/products">Products</a> screen </p>
{% # Snippet input %}
{% snippet "input" |type, name| %}
<div>
<label>{{ type | capitalize }}</label>
<input type={{ type }}>
</div>
{% endsnippet %}
{% snippet "league" %}
<h1>Welcome to the league of super evil</h1>
{% endsnippet %}
{% render "league" %}
{% render "input", type: "text" %}
{% render "input", type: "password" %}
{% endsnippet %}
{% render 'main' %}
</div>
<textarea id="code">
{% capture html %}{% render 'main' %}{% endcapture %}
{{ html | escape }}
</textarea>
<script>
const editorElement = document.querySelector('#code')
const editor = CodeMirror.fromTextArea(editorElement, {
lineNumbers: true,
mode: "htmlmixed",
theme: "dracula"
});
</script>
</body>
</html>
+1
View File
@@ -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) "
+2
View File
@@ -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
+18
View File
@@ -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,
+51
View File
@@ -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
+190
View File
@@ -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" %}
<input />
{% endsnippet %}
{% snippet "banner" %}
<marquee direction="up" height="100px">
Welcome to my store!
</marquee>
{% endsnippet %}
{%- render "input" -%}
{%- render "banner" -%}
LIQUID
expected = <<~OUTPUT
<input />
<marquee direction="up" height="100px">
Welcome to my store!
</marquee>
OUTPUT
assert_template_result(expected, template)
end
def test_render_inline_snippet_with_argument
template = <<~LIQUID.strip
{% snippet "input" |type| %}
<input type="{{ type }}" />
{% endsnippet %}
{%- render "input", type: "text" -%}
LIQUID
expected = <<~OUTPUT
<input type="text" />
OUTPUT
assert_template_result(expected, template)
end
def test_render_inline_snippet_with_multiple_arguments
template = <<~LIQUID.strip
{% snippet "input" |type, value| %}
<input type="{{ type }}" value="{{ value }}" />
{% endsnippet %}
{%- render "input", type: "text", value: "Hello" -%}
LIQUID
expected = <<~OUTPUT
<input type="text" value="Hello" />
OUTPUT
assert_template_result(expected, template)
end
def test_render_inline_snippets_using_same_argument_name
template = <<~LIQUID.strip
{% snippet "input" |type| %}
<input type="{{ type }}" />
{% endsnippet %}
{% snippet "inputs" |type, value| %}
<input type="{{ type }}" value="{{ value }}" />
{% endsnippet %}
{%- render "input", type: "text" -%}
{%- render "inputs", type: "password", value: "pass" -%}
LIQUID
expected = <<~OUTPUT
<input type="text" />
<input type="password" value="pass" />
OUTPUT
assert_template_result(expected, template)
end
def test_render_inline_snippet_empty_string_when_missing_argument
template = <<~LIQUID.strip
{% snippet "input" |type| %}
<input type="{{ type }}" value="{{ value }}" />
{% endsnippet %}
{%- render "input", type: "text" -%}
LIQUID
expected = <<~OUTPUT
<input type="text" value="" />
OUTPUT
assert_template_result(expected, template)
end
def test_render_inline_snippet_shouldnt_leak_context
template = <<~LIQUID.strip
{% snippet "input" |type, value| %}
<input type="{{ type }}" value="{{ value }}" />
{% endsnippet %}
{%- render "input", type: "text", value: "Hello" -%}
{{ type }}
{{ value }}
LIQUID
expected = <<~OUTPUT
<input type="text" value="Hello" />
OUTPUT
assert_template_result(expected, template)
end
def test_render_multiple_inline_snippets_without_leaking_context
template = <<~LIQUID.strip
{% snippet "input" |type| %}
<input type="{{ type }}" />
{% endsnippet %}
{% snippet "no_leak" %}
<input type="{{ type }}" />
{% endsnippet %}
{%- render "input", type: "text" -%}
{%- render "no_leak" -%}
LIQUID
expected = <<~OUTPUT
<input type="text" />
<input type="" />
OUTPUT
assert_template_result(expected, template)
end
end