├── test ├── solid │ ├── integration │ │ ├── scenarios │ │ │ ├── raw │ │ │ │ ├── input.json │ │ │ │ └── input.liquid │ │ │ ├── break │ │ │ │ ├── input.json │ │ │ │ └── input.liquid │ │ │ ├── continue │ │ │ │ ├── input.json │ │ │ │ └── input.liquid │ │ │ ├── liquid │ │ │ │ ├── input.json │ │ │ │ └── input.liquid │ │ │ ├── shop │ │ │ │ ├── input.json │ │ │ │ └── input.liquid │ │ │ ├── decrement │ │ │ │ ├── input.json │ │ │ │ └── input.liquid │ │ │ ├── for-render │ │ │ │ ├── input.json │ │ │ │ ├── _inner.liquid │ │ │ │ └── input.liquid │ │ │ ├── not-liquid │ │ │ │ ├── input.json │ │ │ │ └── input.liquid │ │ │ ├── products │ │ │ │ ├── input.json │ │ │ │ └── input.liquid │ │ │ ├── inline_comment │ │ │ │ ├── input.json │ │ │ │ └── input.liquid │ │ │ ├── render │ │ │ │ ├── _inner.liquid │ │ │ │ ├── _inner_argument.liquid │ │ │ │ ├── _inner_object.liquid │ │ │ │ ├── _nested2.liquid │ │ │ │ ├── _nested1.liquid │ │ │ │ ├── _item.liquid │ │ │ │ ├── input.json │ │ │ │ └── input.liquid │ │ │ ├── shopping-cart │ │ │ │ ├── input.json │ │ │ │ └── input.liquid │ │ │ ├── comment │ │ │ │ ├── input.json │ │ │ │ └── input.liquid │ │ │ ├── if-assign │ │ │ │ ├── input.json │ │ │ │ └── input.liquid │ │ │ ├── whitespace-control │ │ │ │ ├── input.json │ │ │ │ └── input.liquid │ │ │ ├── capture │ │ │ │ ├── input.json │ │ │ │ └── input.liquid │ │ │ ├── for │ │ │ │ ├── 067 │ │ │ │ │ ├── input.json │ │ │ │ │ └── input.liquid │ │ │ │ ├── input.json │ │ │ │ └── input.liquid │ │ │ ├── increment │ │ │ │ ├── input.json │ │ │ │ └── input.liquid │ │ │ ├── assign │ │ │ │ ├── input.json │ │ │ │ └── input.liquid │ │ │ ├── cycle │ │ │ │ ├── input.json │ │ │ │ └── input.liquid │ │ │ ├── case │ │ │ │ ├── input.json │ │ │ │ └── input.liquid │ │ │ ├── tablerow │ │ │ │ ├── input.json │ │ │ │ └── input.liquid │ │ │ ├── echo │ │ │ │ ├── input.liquid │ │ │ │ └── input.json │ │ │ ├── if │ │ │ │ ├── input.json │ │ │ │ └── input.liquid │ │ │ ├── object │ │ │ │ ├── input.json │ │ │ │ └── input.liquid │ │ │ └── filter │ │ │ │ └── input.json │ │ ├── whitespace │ │ │ ├── object_test.exs │ │ │ ├── assign_tag_test.exs │ │ │ ├── increment_tag_test.exs │ │ │ ├── comment_tag_test.exs │ │ │ ├── cycle_tag_test.exs │ │ │ ├── for_tag_test.exs │ │ │ ├── capture_tag_test.exs │ │ │ ├── break_tag_test.exs │ │ │ ├── continue_tag_test.exs │ │ │ ├── case_tag_test.exs │ │ │ ├── if_tag_test.exs │ │ │ └── unless_tag_test.exs │ │ └── lines_test.exs │ ├── integration_test.exs │ ├── matcher_test.exs │ ├── tags │ │ ├── comment_tag_test.exs │ │ ├── break_tag_test.exs │ │ ├── continue_tag_test.exs │ │ ├── echo_tag_test.exs │ │ ├── raw_tag_test.exs │ │ ├── inline_comment_tag_test.exs │ │ ├── capture_tag_test.exs │ │ ├── cycle_tag_test.exs │ │ ├── counter_tag_test.exs │ │ ├── assign_tag_test.exs │ │ └── tablerow_tag_test.exs │ ├── literal_test.exs │ ├── binary_condition_test.exs │ ├── sigil_test.exs │ ├── standard_filter_test.exs │ ├── range_test.exs │ ├── object_test.exs │ ├── condition_expression_test.exs │ └── variable_test.exs ├── test_helper.exs ├── liquid.rb └── support │ ├── custom_filters.ex │ ├── custom_tags.ex │ ├── whitespace_trim_helper.ex │ └── helpers.ex ├── .tool-versions ├── .formatter.exs ├── lib └── solid │ ├── parser │ └── loc.ex │ ├── renderable.ex │ ├── caching.ex │ ├── caching │ └── no_cache.ex │ ├── argument_error.ex │ ├── access_literal.ex │ ├── undefined_filter_error.ex │ ├── access_variable.ex │ ├── block.ex │ ├── undefined_variable_error.ex │ ├── wrong_filter_arity_error.ex │ ├── number_helper.ex │ ├── filter.ex │ ├── parser_context.ex │ ├── text.ex │ ├── unary_condition.ex │ ├── tags │ ├── break_tag.ex │ ├── continue_tag.ex │ ├── echo_tag.ex │ ├── assign_tag.ex │ ├── capture_tag.ex │ ├── counter_tag.ex │ ├── cycle_tag.ex │ ├── comment_tag.ex │ ├── inline_comment_tag.ex │ ├── raw_tag.ex │ ├── case_tag.ex │ └── if_tag.ex │ ├── literal.ex │ ├── range.ex │ ├── object.ex │ ├── tag.ex │ ├── matcher.ex │ ├── binary_condition.ex │ ├── sigil.ex │ ├── html.ex │ ├── epoch_date_time_parser.ex │ ├── condition_expression.ex │ ├── variable.ex │ ├── file_system.ex │ └── context.ex ├── CONTRIBUTING.md ├── .gitignore ├── LICENSE.md ├── .github └── workflows │ └── main.yml ├── mix.exs ├── CHANGELOG.md └── mix.lock /test/solid/integration/scenarios/raw/input.json: -------------------------------------------------------------------------------- 1 | {} -------------------------------------------------------------------------------- /test/solid/integration/scenarios/break/input.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /test/solid/integration/scenarios/continue/input.json: -------------------------------------------------------------------------------- 1 | {} -------------------------------------------------------------------------------- /test/solid/integration/scenarios/liquid/input.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /test/solid/integration/scenarios/shop/input.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | erlang 27.0 2 | elixir 1.17.2 3 | ruby 3.3.0 4 | -------------------------------------------------------------------------------- /test/solid/integration/scenarios/decrement/input.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /test/solid/integration/scenarios/for-render/input.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /test/solid/integration/scenarios/not-liquid/input.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /test/solid/integration/scenarios/products/input.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /test/solid/integration/scenarios/inline_comment/input.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /test/solid/integration/scenarios/render/_inner.liquid: -------------------------------------------------------------------------------- 1 | This is sample -------------------------------------------------------------------------------- /test/solid/integration/scenarios/shopping-cart/input.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /test/solid/integration/scenarios/comment/input.json: -------------------------------------------------------------------------------- 1 | { "value" : 123 } 2 | -------------------------------------------------------------------------------- /test/solid/integration/scenarios/if-assign/input.json: -------------------------------------------------------------------------------- 1 | { "var" : -1} 2 | -------------------------------------------------------------------------------- /test/solid/integration/scenarios/whitespace-control/input.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /test/solid/integration/scenarios/capture/input.json: -------------------------------------------------------------------------------- 1 | { "var" : [1, 2, 3] } 2 | -------------------------------------------------------------------------------- /test/solid/integration/scenarios/for-render/_inner.liquid: -------------------------------------------------------------------------------- 1 | template {{ index }} -------------------------------------------------------------------------------- /test/solid/integration/scenarios/for/067/input.json: -------------------------------------------------------------------------------- 1 | { "var" : [1, 2, 3]} 2 | -------------------------------------------------------------------------------- /test/solid/integration/scenarios/not-liquid/input.liquid: -------------------------------------------------------------------------------- 1 | { Almost Liquid } 2 | -------------------------------------------------------------------------------- /test/solid/integration/scenarios/render/_inner_argument.liquid: -------------------------------------------------------------------------------- 1 | Hello {{ name }} 2 | -------------------------------------------------------------------------------- /test/solid/integration/scenarios/render/_inner_object.liquid: -------------------------------------------------------------------------------- 1 | {{ product.name }} 2 | -------------------------------------------------------------------------------- /test/solid/integration/scenarios/render/_nested2.liquid: -------------------------------------------------------------------------------- 1 | two 2 | {{ my_var }} 3 | -------------------------------------------------------------------------------- /test/solid/integration/scenarios/render/_nested1.liquid: -------------------------------------------------------------------------------- 1 | one {% render "nested2" %} 2 | -------------------------------------------------------------------------------- /test/solid/integration/scenarios/increment/input.json: -------------------------------------------------------------------------------- 1 | { 2 | "my_existing_number": 4 3 | } 4 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | ExUnit.configure(exclude: :skip, exclude: :integration) 3 | -------------------------------------------------------------------------------- /test/solid/integration/scenarios/assign/input.json: -------------------------------------------------------------------------------- 1 | { 2 | "var" : -1, 3 | "array" : [1, 2, 3] 4 | } 5 | -------------------------------------------------------------------------------- /test/solid/integration/scenarios/break/input.liquid: -------------------------------------------------------------------------------- 1 | this should print 2 | {% break %} 3 | this should not print 4 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /test/solid/integration/scenarios/render/_item.liquid: -------------------------------------------------------------------------------- 1 | {{ item.title }}-{{ forloop.index0 }} {{ forloop.parentloop.index0 }} 2 | -------------------------------------------------------------------------------- /test/solid/integration/scenarios/cycle/input.json: -------------------------------------------------------------------------------- 1 | { 2 | "var1" : { 3 | "var2" : { 4 | "var3" : "one" 5 | } 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /test/solid/integration/scenarios/inline_comment/input.liquid: -------------------------------------------------------------------------------- 1 | {% # comment %} 2 | 3 | {% #### another comment %} 4 | 5 | {%#nopadding %} 6 | -------------------------------------------------------------------------------- /test/solid/integration/scenarios/for-render/input.liquid: -------------------------------------------------------------------------------- 1 | test nested render tag 2 | 3 | {% for i in (1..5) %} 4 | {% render "inner", index: i %} 5 | {% endfor %} 6 | -------------------------------------------------------------------------------- /test/solid/integration/scenarios/whitespace-control/input.liquid: -------------------------------------------------------------------------------- 1 | {% assign name = "Hans" %} 2 | Hello {{- name -}} ! 3 | Hello {{- name }} ! 4 | Hello {{ name -}} ! 5 | -------------------------------------------------------------------------------- /test/solid/integration/scenarios/render/input.json: -------------------------------------------------------------------------------- 1 | { 2 | "outer_product": { 3 | "name": "my_product" 4 | }, 5 | "my_var": "42", 6 | "items" : [{"title" : "one"}, {"title" : "two"}] 7 | } 8 | -------------------------------------------------------------------------------- /lib/solid/parser/loc.ex: -------------------------------------------------------------------------------- 1 | defmodule Solid.Parser.Loc do 2 | @enforce_keys [:line, :column] 3 | defstruct [:line, :column] 4 | @type t :: %__MODULE__{line: pos_integer, column: pos_integer} 5 | end 6 | -------------------------------------------------------------------------------- /test/solid/integration/scenarios/case/input.json: -------------------------------------------------------------------------------- 1 | { 2 | "shipping_method" : { "title" : "Local Pick-Up" }, 3 | "title" : "ABC", 4 | "other" : "ABC", 5 | "condition" : 3, 6 | "product" : "shoes" 7 | } 8 | -------------------------------------------------------------------------------- /lib/solid/renderable.ex: -------------------------------------------------------------------------------- 1 | defprotocol Solid.Renderable do 2 | @spec render(t, Solid.Context.t(), Keyword.t()) :: 3 | {binary | iolist | [t], Solid.Context.t()} 4 | def render(value, context, options) 5 | end 6 | -------------------------------------------------------------------------------- /lib/solid/caching.ex: -------------------------------------------------------------------------------- 1 | defmodule Solid.Caching do 2 | @callback get(key :: term) :: {:ok, Solid.Template.t()} | {:error, :not_found} 3 | 4 | @callback put(key :: term, Solid.Template.t()) :: :ok | {:error, term} 5 | end 6 | -------------------------------------------------------------------------------- /test/solid/integration/scenarios/for/067/input.liquid: -------------------------------------------------------------------------------- 1 | Example: 2 | {% for value in var %} 3 | {% assign new_value = value %} 4 | {% if value > 2 %} 5 | Got: {{ value }} 6 | {% endif %} 7 | {% endfor %} 8 | {{ new_value }} 9 | -------------------------------------------------------------------------------- /test/solid/integration/scenarios/decrement/input.liquid: -------------------------------------------------------------------------------- 1 | {% decrement variable %} {% decrement variable %} {% decrement variable %} 2 | {% decrement x %} {% decrement x %} {% increment x %} 3 | {% decrement y %}{{ y }} {% decrement y %}{{ y }} 4 | -------------------------------------------------------------------------------- /test/solid/integration/whitespace/object_test.exs: -------------------------------------------------------------------------------- 1 | import WhitespaceTrimHelper 2 | 3 | test_permutations "object" do 4 | """ 5 | ######################## 6 | 7 | {{ 'abc' }} 8 | 9 | ######################## 10 | """ 11 | end 12 | -------------------------------------------------------------------------------- /lib/solid/caching/no_cache.ex: -------------------------------------------------------------------------------- 1 | defmodule Solid.Caching.NoCache do 2 | @behaviour Solid.Caching 3 | 4 | @impl true 5 | def get(_cache_key), do: {:error, :not_found} 6 | 7 | @impl true 8 | def put(_cache_key, _value), do: :ok 9 | end 10 | -------------------------------------------------------------------------------- /test/solid/integration/scenarios/raw/input.liquid: -------------------------------------------------------------------------------- 1 | {% raw %}{{ 5 | plus: 6 }}{% endraw %} equals 11. 2 | {% raw %} {{ {% {% {% endraw %} equals 11. 3 | {% raw %}{% increment counter %}{{ counter }}{% endraw %} 4 | {% increment counter %} {{ counter }} 5 | -------------------------------------------------------------------------------- /lib/solid/argument_error.ex: -------------------------------------------------------------------------------- 1 | defmodule Solid.ArgumentError do 2 | @type t :: %__MODULE__{} 3 | defexception [:message, :loc] 4 | 5 | @impl true 6 | def message(exception) do 7 | "Liquid error (line #{exception.loc.line}): #{exception.message}" 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /test/solid/integration/scenarios/continue/input.liquid: -------------------------------------------------------------------------------- 1 | {% for i in (1..5) %} 2 | {% if i == 4 %} 3 | x 4 | {% continue %} 5 | {% else %} 6 | {{ i }} 7 | {% endif %} 8 | {% endfor %} 9 | 10 | this should print 11 | {% continue %} 12 | this should not print 13 | -------------------------------------------------------------------------------- /test/solid/integration/whitespace/assign_tag_test.exs: -------------------------------------------------------------------------------- 1 | import WhitespaceTrimHelper 2 | 3 | test_permutations "assign tag" do 4 | """ 5 | ######################## 6 | 7 | {% assign food = 'pizza' %} 8 | 9 | {{ food }} 10 | 11 | ######################## 12 | """ 13 | end 14 | -------------------------------------------------------------------------------- /test/solid/integration/scenarios/tablerow/input.json: -------------------------------------------------------------------------------- 1 | {"products": [ 2 | { 3 | "title": "glass", 4 | "type": "kitchen" 5 | }, 6 | { 7 | "title": "pan", 8 | "type": "kitchen" 9 | }, 10 | { 11 | "title": "vase", 12 | "type": "livingroom" 13 | } 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /lib/solid/access_literal.ex: -------------------------------------------------------------------------------- 1 | defmodule Solid.AccessLiteral do 2 | @enforce_keys [:loc, :value] 3 | defstruct [:loc, :value] 4 | @type t :: %__MODULE__{loc: Solid.Parser.Loc.t(), value: integer | binary} 5 | 6 | defimpl String.Chars do 7 | def to_string(access), do: inspect(access.value) 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /test/solid/integration/whitespace/increment_tag_test.exs: -------------------------------------------------------------------------------- 1 | import WhitespaceTrimHelper 2 | 3 | test_permutations "increment tag", ~s({ "counter" : 0 }) do 4 | """ 5 | ######################## 6 | 7 | {% increment counter %} 8 | 9 | {% increment counter %} 10 | 11 | ######################## 12 | """ 13 | end 14 | -------------------------------------------------------------------------------- /lib/solid/undefined_filter_error.ex: -------------------------------------------------------------------------------- 1 | defmodule Solid.UndefinedFilterError do 2 | @type t :: %__MODULE__{} 3 | defexception [:filter, :loc] 4 | 5 | @impl true 6 | def message(exception) do 7 | line = exception.loc.line 8 | reason = "Undefined filter #{exception.filter}" 9 | "#{line}: #{reason}" 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /test/solid/integration/whitespace/comment_tag_test.exs: -------------------------------------------------------------------------------- 1 | import WhitespaceTrimHelper 2 | 3 | test_permutations "comment tag" do 4 | """ 5 | ######################## 6 | 7 | {{ ' I am a object' }} 8 | {% comment %} 9 | 10 | This is a comment 11 | 12 | {% endcomment %} 13 | 14 | ######################## 15 | """ 16 | end 17 | -------------------------------------------------------------------------------- /lib/solid/access_variable.ex: -------------------------------------------------------------------------------- 1 | defmodule Solid.AccessVariable do 2 | @enforce_keys [:loc, :variable] 3 | defstruct [:loc, :variable] 4 | @type t :: %__MODULE__{loc: Solid.Parser.Loc.t(), variable: Solid.Variable.t()} 5 | 6 | defimpl String.Chars do 7 | def to_string(access), do: Kernel.to_string(access.variable) 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/solid/block.ex: -------------------------------------------------------------------------------- 1 | defprotocol Solid.Block do 2 | @fallback_to_any true 3 | @spec blank?(t) :: boolean 4 | def blank?(body) 5 | end 6 | 7 | defimpl Solid.Block, for: Any do 8 | def blank?(_body), do: false 9 | end 10 | 11 | defimpl Solid.Block, for: List do 12 | def blank?(list), do: Enum.all?(list, &Solid.Block.blank?/1) 13 | end 14 | -------------------------------------------------------------------------------- /test/solid/integration/scenarios/echo/input.liquid: -------------------------------------------------------------------------------- 1 | {% echo 'a string' %} 2 | {% echo string %} 3 | {% echo string | upcase | split: ',' %} 4 | 5 | {% echo integer %} 6 | {% echo integers %} 7 | 8 | {% echo float %} 9 | {% echo floats %} 10 | 11 | {% echo var[test.var2][context.locale] %} 12 | {% echo var[test["var2"]][context.locale] %} 13 | -------------------------------------------------------------------------------- /test/solid/integration/whitespace/cycle_tag_test.exs: -------------------------------------------------------------------------------- 1 | import WhitespaceTrimHelper 2 | 3 | test_permutations "cycle tag" do 4 | """ 5 | ######################## 6 | 7 | {% cycle "one", "two", "three" %} 8 | 9 | {% cycle "one", "two", "three" %} 10 | 11 | {% cycle "one", "two", "three" %} 12 | 13 | ######################## 14 | """ 15 | end 16 | -------------------------------------------------------------------------------- /lib/solid/undefined_variable_error.ex: -------------------------------------------------------------------------------- 1 | defmodule Solid.UndefinedVariableError do 2 | @type t :: %__MODULE__{} 3 | defexception [:variable, :original_name, :loc] 4 | 5 | @impl true 6 | def message(exception) do 7 | line = exception.loc.line 8 | reason = "Undefined variable #{exception.original_name}" 9 | "#{line}: #{reason}" 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /test/solid/integration/whitespace/for_tag_test.exs: -------------------------------------------------------------------------------- 1 | import WhitespaceTrimHelper 2 | 3 | test_permutations "for tag", ~s({ "var" : [1, 2, 3]}) do 4 | """ 5 | ######################## 6 | 7 | {% for value in var %} 8 | 9 | {% if value > 2 %} 10 | Got: {{ value }} 11 | {% endif %} 12 | 13 | {% endfor %} 14 | 15 | ######################## 16 | """ 17 | end 18 | -------------------------------------------------------------------------------- /lib/solid/wrong_filter_arity_error.ex: -------------------------------------------------------------------------------- 1 | defmodule Solid.WrongFilterArityError do 2 | @type t :: %__MODULE__{} 3 | defexception [:filter, :expected_arity, :arity, :loc] 4 | 5 | @impl true 6 | def message(exception) do 7 | "Liquid error (line #{exception.loc.line}): wrong number of arguments (given #{exception.arity}, expected #{exception.expected_arity})" 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /test/solid/integration/whitespace/capture_tag_test.exs: -------------------------------------------------------------------------------- 1 | import WhitespaceTrimHelper 2 | 3 | test_permutations "capture tag" do 4 | """ 5 | ######################## 6 | 7 | {% capture string_with_newlines_and_whitespace %} 8 | 9 | Hello 10 | 11 | there 12 | 13 | {% endcapture %} 14 | 15 | {{ string_with_newlines_and_whitespace }} 16 | 17 | ######################## 18 | """ 19 | end 20 | -------------------------------------------------------------------------------- /test/solid/integration/scenarios/if-assign/input.liquid: -------------------------------------------------------------------------------- 1 | {% if var == -1 %} 2 | equal 3 | {% else %} 4 | not equal 5 | {% endif %} 6 | 7 | {% if var %} 8 | true 9 | {% else %} 10 | false 11 | {% endif %} 12 | 13 | %{ assign var = true %} 14 | 15 | {% if var %} 16 | true 17 | {% else %} 18 | false 19 | {% endif %} 20 | 21 | {% liquid 22 | if var 23 | echo 'true' 24 | else 25 | echo 'false' 26 | endif 27 | %} 28 | -------------------------------------------------------------------------------- /lib/solid/number_helper.ex: -------------------------------------------------------------------------------- 1 | defmodule Solid.NumberHelper do 2 | @moduledoc false 3 | 4 | @spec to_integer(term) :: {:ok, integer} | {:error, binary} 5 | def to_integer(input) when is_integer(input), do: {:ok, input} 6 | 7 | def to_integer(input) do 8 | case Integer.parse(to_string(input)) do 9 | {integer, _} -> {:ok, integer} 10 | _ -> {:error, "invalid integer"} 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/solid/filter.ex: -------------------------------------------------------------------------------- 1 | defmodule Solid.Filter do 2 | @enforce_keys [:loc, :function, :positional_arguments, :named_arguments] 3 | defstruct [:loc, :function, :positional_arguments, :named_arguments] 4 | 5 | @type t :: %__MODULE__{ 6 | loc: Solid.Parser.Loc.t(), 7 | function: binary, 8 | positional_arguments: [Solid.Argument.t()], 9 | named_arguments: %{binary => Solid.Argument.t()} 10 | } 11 | end 12 | -------------------------------------------------------------------------------- /lib/solid/parser_context.ex: -------------------------------------------------------------------------------- 1 | defmodule Solid.ParserContext do 2 | alias Solid.Lexer 3 | 4 | @type t :: %__MODULE__{ 5 | rest: binary, 6 | line: Lexer.line(), 7 | column: Lexer.column(), 8 | mode: :normal | :liquid_tag, 9 | tags: %{String.t() => module} | nil 10 | } 11 | 12 | @enforce_keys [:rest, :line, :column, :mode] 13 | defstruct [:rest, :line, :column, :mode, tags: nil] 14 | end 15 | -------------------------------------------------------------------------------- /lib/solid/text.ex: -------------------------------------------------------------------------------- 1 | defmodule Solid.Text do 2 | @enforce_keys [:loc, :text] 3 | defstruct [:loc, :text] 4 | @type t :: %__MODULE__{loc: Solid.Parser.Loc.t(), text: binary} 5 | 6 | defimpl Solid.Renderable do 7 | def render(text, context, _options) do 8 | {text.text, context} 9 | end 10 | end 11 | 12 | defimpl Solid.Block do 13 | def blank?(text) do 14 | String.trim(text.text) == "" 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /test/solid/integration/scenarios/echo/input.json: -------------------------------------------------------------------------------- 1 | { 2 | "string" : "1, 2, 3", 3 | "integer" : 1, 4 | "integers" : [1, 2, 3], 5 | "float" : 0.333, 6 | "floats" : [0.333, 0.666, 1.0], 7 | "hash" : { 8 | "key" : "value", 9 | "numbers" : [1, 0.2] 10 | }, 11 | "var" : { 12 | "value1" : { 13 | "pt_BR" : "sucesso" 14 | } 15 | }, 16 | "test" : { 17 | "var2" : "value1" 18 | }, 19 | "context" : { 20 | "locale" : "pt_BR" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Solid 2 | 3 | Thanks for taking the time to help us build Solid 🎉 4 | 5 | The most important part of our test suite is the directory `test/solid/integration/scenarios`. 6 | We have `input.liquid` files that are tested against Solid and against the Liquid gem. 7 | 8 | Whenever possible we want to include a new test case that covers the new feature that was added or bug that has been fixed. So if it makes sense please include a new test case or change an existing one that makes sense. 9 | -------------------------------------------------------------------------------- /test/solid/integration/scenarios/if/input.json: -------------------------------------------------------------------------------- 1 | { 2 | "var" : null, 3 | "array" : [], 4 | "var2" : 1, 5 | "string-array": ["a", "b", "c"], 6 | "pages": { 7 | "about-us" : { 8 | "title" : "About Us" 9 | } 10 | }, 11 | "empty_hash" : {}, 12 | "var3" : -1, 13 | "site": { 14 | "pages": [1, 2, 3, 4, 5], 15 | "size" : 5, 16 | "page_refs": { 17 | "ref1" : "http://", 18 | "ref2" : "http://" 19 | } 20 | }, 21 | "enabled?" : true, 22 | "enabled2?" : false 23 | } 24 | -------------------------------------------------------------------------------- /lib/solid/unary_condition.ex: -------------------------------------------------------------------------------- 1 | defmodule Solid.UnaryCondition do 2 | defstruct [:loc, :child_condition, :argument, argument_filters: []] 3 | 4 | @type t :: %__MODULE__{ 5 | loc: Solid.Parser.Loc.t(), 6 | argument: Solid.Argument.t(), 7 | argument_filters: [Solid.Filter.t()], 8 | child_condition: {:and | :or, t | Solid.BinaryCondition.t()} 9 | } 10 | 11 | def eval(value) do 12 | if value do 13 | true 14 | else 15 | false 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /test/liquid.rb: -------------------------------------------------------------------------------- 1 | require 'liquid' 2 | require 'json' 3 | require 'time' 4 | 5 | ENV['TZ'] = 'UTC' 6 | 7 | module SubstituteFilter 8 | def substitute(input, params = {}) 9 | input.gsub(/%\{(\w+)\}/) { |_match| params[Regexp.last_match(1)] } 10 | end 11 | end 12 | 13 | if ARGV[2] 14 | Liquid::Environment.default.file_system = Liquid::LocalFileSystem.new(ARGV[2]) 15 | end 16 | 17 | context = Liquid::Context.new(JSON.parse(ARGV[1])) 18 | context.add_filters(SubstituteFilter) 19 | 20 | 21 | puts Liquid::Template.parse(ARGV[0], error_mode: :strict, line_numbers: true).render(context) 22 | -------------------------------------------------------------------------------- /test/solid/integration/scenarios/comment/input.liquid: -------------------------------------------------------------------------------- 1 | Anything you put between {% comment %} and {% endcomment %} tags 2 | is turned into a comment. 3 | 4 | {{ value }} 5 | {% comment %} 6 | {{ value }} 7 | {% endcomment %} 8 | 9 | {% comment %} 10 | {% assign value = 12 %} 11 | {{ value }} 12 | {% endcomment %} 13 | 14 | {{ value }} 15 | 16 | test comment block contain invalid syntax 17 | 18 | {% comment %} 19 | hi {% myblock %} 20 | {{ and this is so wrong }} 21 | {% endcomment %} 22 | 23 | {% comment arg1 arg2 arg3 %} 24 | {% endcomment arg1 arg2 arg3 %} 25 | 26 | {% liquid 27 | comment 28 | echo 'true' 29 | endcomment %} 30 | -------------------------------------------------------------------------------- /test/solid/integration/scenarios/for/input.json: -------------------------------------------------------------------------------- 1 | { 2 | "var" : [1,2,3,4], 3 | "values" : [1, 2, 3, 4, 5], 4 | "array" : [1, 2, 3, 4, 5, 6], 5 | "evens" : [2, 4, 6, 8, 10, 12], 6 | "string" : "Hello World", 7 | "outer" : [[1, 2, 3], [1, 2, 3]], 8 | "item" : { 9 | "labels" : ["foo", "bar", "baz"] 10 | }, 11 | "collection" : { "key" : "value" }, 12 | "products": [ 13 | { 14 | "title": "glas", 15 | "type": "kitchen" 16 | }, 17 | { 18 | "title": "pan", 19 | "type": "kitchen" 20 | }, 21 | { 22 | "title": "vase", 23 | "type": "livingroom" 24 | } 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /test/solid/integration/whitespace/break_tag_test.exs: -------------------------------------------------------------------------------- 1 | import WhitespaceTrimHelper 2 | 3 | # test_permutations "break tag outside for body" do 4 | # """ 5 | # ######################## 6 | 7 | # this should print 8 | 9 | # {% break %} 10 | 11 | # this should not print 12 | 13 | # ######################## 14 | # """ 15 | # end 16 | 17 | test_permutations "break tag inside for body" do 18 | """ 19 | ######################## 20 | 21 | {% for i in (1..5) %} 22 | 23 | {% if i == 4 %} 24 | 25 | x 26 | 27 | {% break %} 28 | 29 | {% else %} 30 | 31 | {{ i }} 32 | 33 | {% endif %} 34 | 35 | {% endfor %} 36 | 37 | ######################## 38 | """ 39 | end 40 | -------------------------------------------------------------------------------- /test/solid/integration/scenarios/object/input.json: -------------------------------------------------------------------------------- 1 | { 2 | "foo" : "bar", 3 | "bar" : "baz", 4 | "var" : [1, 2, 3], 5 | "numbers" : { "zero" : 0, "one" : 1, "two" : 2 }, 6 | "multiarray" : [["a", "b", "c"], [1, 2, 3]], 7 | "string" : "string", 8 | "number" : 12, 9 | "empty" : [1, 2], 10 | "true" : [1, 2], 11 | "false" : [1, 2], 12 | "nil" : [1, 2], 13 | "blank" : [1, 2], 14 | "complexarray" : [[1, 2, 3], [4, 5, 6], [7, [8, 9, "ten"]]], 15 | "list" : [{ "key" : "about-us" }, { "key" : "sitemap" }], 16 | "name": "John", 17 | "hash" : { 18 | "key" : "value", 19 | "numbers" : [1, [], 0.2] 20 | }, 21 | "question?" : "?", 22 | "a var" : { 23 | "foo" : "bar" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /test/solid/integration/whitespace/continue_tag_test.exs: -------------------------------------------------------------------------------- 1 | import WhitespaceTrimHelper 2 | 3 | # test_permutations "continue tag outside for body" do 4 | # """ 5 | # ######################## 6 | 7 | # this should print 8 | 9 | # {% continue %} 10 | 11 | # this should not print 12 | 13 | # ######################## 14 | # """ 15 | # end 16 | 17 | test_permutations "continue tag inside for body" do 18 | """ 19 | ######################## 20 | 21 | {% for i in (1..5) %} 22 | 23 | {% if i == 4 %} 24 | 25 | x 26 | 27 | {% continue %} 28 | 29 | {% else %} 30 | 31 | {{ i }} 32 | 33 | {% endif %} 34 | 35 | {% endfor %} 36 | 37 | ######################## 38 | """ 39 | end 40 | -------------------------------------------------------------------------------- /lib/solid/tags/break_tag.ex: -------------------------------------------------------------------------------- 1 | defmodule Solid.Tags.BreakTag do 2 | @enforce_keys [:loc] 3 | defstruct [:loc] 4 | 5 | @behaviour Solid.Tag 6 | 7 | @impl true 8 | def parse("break", loc, context) do 9 | with {:ok, tokens, context} <- Solid.Lexer.tokenize_tag_end(context), 10 | {:tokens, [{:end, _}]} <- {:tokens, tokens} do 11 | {:ok, %__MODULE__{loc: loc}, context} 12 | else 13 | {:tokens, tokens} -> {:error, "Unexpected token", Solid.Parser.meta_head(tokens)} 14 | {:error, reason, _rest, loc} -> {:error, reason, loc} 15 | end 16 | end 17 | 18 | defimpl Solid.Renderable do 19 | def render(_tag, context, _options) do 20 | throw({:break_exp, [], context}) 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/solid/tags/continue_tag.ex: -------------------------------------------------------------------------------- 1 | defmodule Solid.Tags.ContinueTag do 2 | @enforce_keys [:loc] 3 | defstruct [:loc] 4 | 5 | @behaviour Solid.Tag 6 | 7 | @impl true 8 | def parse("continue", loc, context) do 9 | with {:ok, tokens, context} <- Solid.Lexer.tokenize_tag_end(context), 10 | {:tokens, [{:end, _}]} <- {:tokens, tokens} do 11 | {:ok, %__MODULE__{loc: loc}, context} 12 | else 13 | {:tokens, tokens} -> {:error, "Unexpected token", Solid.Parser.meta_head(tokens)} 14 | {:error, reason, _rest, loc} -> {:error, reason, loc} 15 | end 16 | end 17 | 18 | defimpl Solid.Renderable do 19 | def render(_tag, context, _options) do 20 | throw({:continue_exp, [], context}) 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where third-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | solid-*.tar 24 | 25 | # Temporary files, for example, from tests. 26 | /tmp/ 27 | 28 | # Misc. 29 | /src/solid_parser.erl 30 | .DS_Store 31 | -------------------------------------------------------------------------------- /test/support/custom_filters.ex: -------------------------------------------------------------------------------- 1 | defmodule Solid.CustomFilters do 2 | def date_year(input) do 3 | date = Date.from_iso8601!(input) 4 | date.year 5 | end 6 | 7 | def date_format(input, format) when is_binary(input), 8 | do: input |> Date.from_iso8601!() |> date_format(format) 9 | 10 | def date_format(date, "m/d/yyyy"), 11 | do: Enum.join([date.month, date.day, date.year], "/") 12 | 13 | def date_format(date, "d.m.yyyy"), 14 | do: Enum.join([date.day, date.month, date.year], ".") 15 | 16 | def date_format(date, _format), 17 | do: to_string(date) 18 | 19 | def substitute(message, bindings \\ %{}) do 20 | Regex.replace(~r/%\{(\w+)\}/, message, fn _, key -> Map.get(bindings, key) || "" end) 21 | end 22 | 23 | def asset_url(input) do 24 | input 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/solid/tags/echo_tag.ex: -------------------------------------------------------------------------------- 1 | defmodule Solid.Tags.EchoTag do 2 | @enforce_keys [:loc, :object] 3 | defstruct [:loc, :object] 4 | 5 | @behaviour Solid.Tag 6 | 7 | @impl true 8 | def parse("echo", loc, context) do 9 | with {:ok, tokens, context} <- Solid.Lexer.tokenize_tag_end(context), 10 | {:ok, object, [{:end, _}]} <- Solid.Object.parse(tokens) do 11 | {:ok, %__MODULE__{loc: loc, object: object}, context} 12 | else 13 | {:error, reason, _rest, loc} -> {:error, reason, loc} 14 | error -> error 15 | end 16 | end 17 | 18 | defimpl Solid.Renderable do 19 | def render(tag, context, options) do 20 | {:ok, value, context} = 21 | Solid.Argument.render(tag.object.argument, context, tag.object.filters, options) 22 | 23 | {[value], context} 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /test/solid/integration/scenarios/render/input.liquid: -------------------------------------------------------------------------------- 1 | render template 2 | {% render "inner" %} 3 | 4 | render template with named argument and literal value 5 | {% assign two = "should not be visible inside the inner_argument template" %} 6 | {% render "inner_argument", name: "Sam", one: "1", two: 2, var1: 3, a: "23" %} 7 | 8 | render template and pass argument to inner scope 9 | Yes comma 10 | {% render "inner_object", product: outer_product %} 11 | No comma 12 | {% render "inner_object" product: outer_product %} 13 | 14 | rendered template should not inherit parent context 15 | 16 | test nested render tag 17 | 18 | {% render "nested1", my_var: my_var %} 19 | 20 | render with 21 | {% assign my_name = "Hello" %} 22 | {% render "inner_argument" with my_name as name %} 23 | 24 | {% for i in (1..3) %}{% render 'item' for items %}{% endfor %} 25 | -------------------------------------------------------------------------------- /test/solid/integration/scenarios/liquid/input.liquid: -------------------------------------------------------------------------------- 1 | {% liquid 2 | echo var1 3 | # A comment 4 | echo var2 5 | %} 6 | 7 | {% if true %} 8 | {% liquid 9 | # comment 10 | echo '456' 11 | %} 12 | {% endif %} 13 | 14 | {% if true %} 15 | {% liquid 16 | if true 17 | echo 'true inside' 18 | endif 19 | %} 20 | {% endif %} 21 | 22 | {% echo '123' %} 23 | 24 | {% liquid 25 | assign var3 = 'value3' 26 | %} 27 | 28 | {% liquid 29 | comment 30 | completely ignored 31 | === 32 | {{ 33 | endcomment 34 | %} 35 | 36 | {% liquid 37 | comment first line 38 | second line 39 | endcomment text 40 | %} 41 | 42 | {% liquid 43 | comment this is a comment 44 | endcomment 45 | %} 46 | 47 | {% liquid 48 | ## 49 | # inline comment 50 | ## 51 | #inlinecomment 52 | %} 53 | 54 | 55 | {%- liquid echo "a" -%} 56 | b 57 | {%- liquid echo "c" -%} 58 | 59 | {{ value3 }} 60 | -------------------------------------------------------------------------------- /test/solid/integration/scenarios/assign/input.liquid: -------------------------------------------------------------------------------- 1 | {% assign num = 4 %} 2 | {{ num }} 3 | 4 | {% assign beatles = "John, Paul, George, Ringo" %} 5 | 6 | {{ beatles | split: ", " | join: " " }} 7 | 8 | {% assign myvariable1 = 123 %} 9 | {% assign myvariable = myvariable1 | plus: 999 %} 10 | 11 | {{ myvariable }} 12 | 13 | {% assign variable = var | plus: 333 %} 14 | 15 | {{ variable }} 16 | 17 | {% assign integer = "123" | plus: 333 %} 18 | 19 | {{ integer }} 20 | 21 | {% assign float = "123.5" | plus: 333 %} 22 | 23 | {{ float }} 24 | 25 | {% assign nan = "123.5ABC" | plus: 333 %} 26 | 27 | {{ float }} 28 | 29 | {% assign other_variable = missing %} 30 | 31 | {{ other_variable }} 32 | 33 | {% assign list = array %} {{ array[1] }} 34 | 35 | {% assign new[1] = 123 %} {{ new[1] }} {{ new }} 36 | 37 | {% assign foo = (1..3) %}{{ foo | join: '#' }} 38 | {% assign 123 = 'hello' %}{{ 123 }} {{ 456 }}" 39 | -------------------------------------------------------------------------------- /test/solid/integration/scenarios/capture/input.liquid: -------------------------------------------------------------------------------- 1 | {% assign favorite_food = 'pizza' %} 2 | {% assign age = 35 %} 3 | 4 | {% capture about_me %} 5 | I am {{ age }} and my favorite food is {{ favorite_food }}. 6 | {% assign new = 10 %} 7 | {% endcapture %} 8 | 9 | {{ about_me }} 10 | {{ new }} 11 | 12 | {% capture string_with_newlines %} 13 | Hello 14 | there 15 | {% endcapture %} 16 | 17 | {{ string_with_newlines | strip_newlines }} 18 | 19 | 20 | {% capture string_with_newlines %} 21 | Hello 22 | there 23 | {% endcapture %} 24 | 25 | {{ string_with_newlines | newline_to_br }} 26 | 27 | {% capture captured %} 28 | World 29 | {% endcapture %} 30 | 31 | Hello, {{ captured | strip }} 32 | 33 | {% capture var[1] %} a {% endcapture %} {{ var[1] }} {{ var }} 34 | 35 | !{% if true %}\n\n{% capture foo %}{% endcapture %}\n\n{% endif %}! 36 | 37 | !{% if true %}\n\n{% capture foo %}\n{{ '' }}\n{% endcapture %}\n\n{% endif %}! 38 | {% capture 123 %}hello{% endcapture %}{{ 123 }} 39 | -------------------------------------------------------------------------------- /test/solid/integration_test.exs: -------------------------------------------------------------------------------- 1 | scenarios_dir = "test/solid/integration/scenarios" 2 | 3 | for scenario <- File.ls!(scenarios_dir) do 4 | module_name = Module.concat([Solid.Integration.Scenarios, :"#{scenario}Test"]) 5 | 6 | defmodule module_name do 7 | use ExUnit.Case, async: true 8 | import Solid.Helpers 9 | @moduletag :integration 10 | 11 | @liquid_input_file "#{scenarios_dir}/#{scenario}/input.liquid" 12 | @json_input_file "#{scenarios_dir}/#{scenario}/input.json" 13 | @template_directory "#{scenarios_dir}/#{scenario}" 14 | @external_resource @liquid_input_file 15 | @external_resource @json_input_file 16 | 17 | @tag scenario: scenario 18 | test "scenario #{scenario}" do 19 | liquid_input = File.read!(@liquid_input_file) 20 | json_input = File.read!(@json_input_file) 21 | opts = [custom_filters: Solid.CustomFilters] 22 | assert_render(liquid_input, json_input, @template_directory, opts) 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /test/solid/integration/scenarios/filter/input.json: -------------------------------------------------------------------------------- 1 | { 2 | "words" : ["a", null, "b", null, "c"], 3 | "hashes" : [{ "a" : "A" }, { "a" : null }, { "a" : "C "}], 4 | "var" : 1000, 5 | "float" : 1000.0, 6 | "array" : ["a", "B", "c"], 7 | "nested_array" : [["a", ["b"]], ["c", "d"]], 8 | "numbers": [{ "key" : 1 }, { "key" : 10 }, { "key" : 20 }], 9 | "my_array" : [1, 2, 3], 10 | "my_numbers" : [1, 2.0, 3], 11 | "my_floats" : [1.1, 1.9, 3], 12 | "string" : "A string", 13 | "fruits" : "apples, oranges, peaches, plums", 14 | "input": "hello %{first_name}, %{last_name}", "surname": "john", 15 | "my_string" : "abba abba cde cde", 16 | "posts" : [{ "title" : "Title 1" }, { "title" : null }, [{"title" : "Title 3"}]], 17 | "weird_posts" : [{ "title" : "Title 1" }, { "title" : "Title 2" }, 123, []], 18 | "post" : { "title" : "Title 1" }, 19 | "var_nil": null, 20 | "var_false": false, 21 | "var_empty_array": [], 22 | "var_empty_string": "", 23 | "var_empty_hash": {} 24 | } 25 | -------------------------------------------------------------------------------- /lib/solid/literal.ex: -------------------------------------------------------------------------------- 1 | defmodule Solid.Literal do 2 | alias Solid.Lexer 3 | alias Solid.Parser.Loc 4 | 5 | defmodule Empty do 6 | defstruct [] 7 | end 8 | 9 | @enforce_keys [:loc, :value] 10 | defstruct [:loc, :value] 11 | 12 | @type value :: boolean | nil | binary | integer | float | %Empty{} 13 | @type t :: %__MODULE__{loc: Loc.t(), value: value} 14 | 15 | defimpl String.Chars do 16 | def to_string(literal), do: inspect(literal.value) 17 | end 18 | 19 | @spec parse(Lexer.tokens()) :: {:ok, t, Lexer.tokens()} | {:error, binary, Lexer.loc()} 20 | def parse(tokens) do 21 | case tokens do 22 | [{type, meta, value} | rest] when type in [:float, :integer] -> 23 | {:ok, %__MODULE__{loc: struct!(Loc, meta), value: value}, rest} 24 | 25 | [{:string, meta, value, _quotes} | rest] -> 26 | {:ok, %__MODULE__{loc: struct!(Loc, meta), value: value}, rest} 27 | 28 | _ -> 29 | {:error, "Literal expected", Solid.Parser.meta_head(tokens)} 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /test/solid/matcher_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Solid.MatcherTest do 2 | use ExUnit.Case, async: true 3 | 4 | defmodule UserProfile do 5 | defstruct [:full_name] 6 | 7 | defimpl Solid.Matcher do 8 | def match(user_profile, ["full_name"]), do: {:ok, user_profile.full_name} 9 | end 10 | end 11 | 12 | defmodule User do 13 | defstruct [:email] 14 | 15 | def load_profile(%User{} = _user) do 16 | # implementation omitted 17 | %UserProfile{full_name: "John Doe"} 18 | end 19 | 20 | defimpl Solid.Matcher do 21 | def match(user, ["email"]), do: {:ok, user.email} 22 | 23 | def match(user, ["profile" | keys]), 24 | do: user |> User.load_profile() |> @protocol.match(keys) 25 | end 26 | end 27 | 28 | test "should render protocolized struct correctly" do 29 | template = ~s({{ user.email }}: {{ user.profile.full_name }}) 30 | 31 | context = %{ 32 | "user" => %User{email: "test@example.com"} 33 | } 34 | 35 | assert "test@example.com: John Doe" == 36 | template |> Solid.parse!() |> Solid.render!(context) |> to_string() 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # MIT License 2 | 3 | Copyright (c) 2016 Eduardo Gurgel Pinho 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /test/solid/integration/scenarios/cycle/input.liquid: -------------------------------------------------------------------------------- 1 | {% cycle "one", "two", "three" %} 2 | {% cycle 3 | "one", "two", "three" %} 4 | {% cycle "one", "two", "three" %} 5 | {% cycle "one", "two", "three" %} 6 | {% cycle "one", "two", "three" %} 7 | {% cycle "one", "two", "three" %} 8 | {% cycle "one", "two", "three" %} 9 | {% cycle "one", "two", "three" %} 10 | 11 | {% cycle "1one", "2two", "3three" %} 12 | {% cycle "1one", "2two", "3three" %} 13 | {% cycle "1one", "2two", "3three" %} 14 | {% cycle "1one", "2two", "3three" %} 15 | 16 | {% cycle "first": "one", "two", "three" %} 17 | {% cycle "second": "one", "two", "three" %} 18 | {% cycle "second": "one", "two", "three" %} 19 | {% cycle "first": "one", "two", "three" %} 20 | 21 | {% cycle "second": "one", "two", "three" %} 22 | {% cycle "first": "one", "two", "three" %} 23 | 24 | {% cycle "second": "one", "two", "three" %} 25 | {% cycle "first": "one", "two", "three" %} 26 | 27 | {% cycle var1.var2.var3, "two", "three" %} 28 | {% cycle var1.var2.var3, "two", "three" %} 29 | 30 | {% cycle var1: "one", "two", "three" %} 31 | {% cycle var1: "one", "two", "three" %} 32 | {% cycle var1: "one" %} 33 | {% cycle var1: "one", "two", "three" %} 34 | -------------------------------------------------------------------------------- /lib/solid/range.ex: -------------------------------------------------------------------------------- 1 | defmodule Solid.Range do 2 | @moduledoc """ 3 | Range representation 4 | 5 | (first..last) 6 | (1..5) 7 | """ 8 | alias Solid.Parser.Loc 9 | 10 | alias Solid.{Argument, Lexer} 11 | 12 | @type t :: %__MODULE__{ 13 | loc: Loc.t(), 14 | start: Argument.t(), 15 | finish: Argument.t() 16 | } 17 | 18 | @enforce_keys [:loc, :start, :finish] 19 | defstruct [:loc, :start, :finish] 20 | 21 | defimpl String.Chars do 22 | def to_string(range) do 23 | "(#{range.start}..#{range.finish})" 24 | end 25 | end 26 | 27 | @spec parse(Lexer.tokens()) :: {:ok, t, Lexer.tokens()} | {:error, binary, Lexer.loc()} 28 | def parse(tokens) do 29 | with [{:open_round, meta} | tokens] <- tokens, 30 | {:ok, start, tokens} <- Argument.parse(tokens), 31 | [{:dot, _}, {:dot, _} | tokens] <- tokens, 32 | {:ok, finish, tokens} <- Argument.parse(tokens), 33 | [{:close_round, _} | tokens] <- tokens do 34 | {:ok, %__MODULE__{loc: struct!(Loc, meta), start: start, finish: finish}, tokens} 35 | else 36 | _ -> 37 | {:error, "Range expected", Solid.Parser.meta_head(tokens)} 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /test/solid/integration/scenarios/increment/input.liquid: -------------------------------------------------------------------------------- 1 | 7 | 8 | {% increment my_number %} 9 | {% increment my_number %} 10 | {% increment my_number %} 11 | 12 | {{ my_number }} 13 | 14 | {% assign new_number = 22 %} 15 | {% increment new_number %} 16 | 17 | {% assign var[1][2][3] = 44 %} 18 | {% increment var[1][2][3] %} 19 | 20 | {{ new_number }} 21 | 22 | {% increment my_number %} 23 | {{ my_number }} 24 | {% assign my_number = 33 %} 25 | {{ my_number }} 26 | {% increment my_number %} 27 | 28 | {% assign my_number = 33 %} 29 | {% increment my_number %} 30 | {{ my_number }} 31 | {% assign my_number = 42 %} 32 | {{ my_number }} 33 | {% increment my_number %} 34 | 35 | increment with initial value 36 | 37 | {% increment my_existing_number %} 38 | {% increment my_existing_number %} 39 | {% increment my_existing_number %} 40 | 41 | {{ my_existing_number }} 42 | 43 | {% increment new_number[23] %} 44 | {% increment new_number[23] %} 45 | 46 | {% increment new_number['23'] %} 47 | {% increment new_number['23'] %} 48 | 49 | {% increment "yo" %} 50 | {% increment "yo" %} 51 | {% increment "yo" %} 52 | -------------------------------------------------------------------------------- /test/solid/integration/lines_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Solid.Integration.LinesTest do 2 | use ExUnit.Case, async: true 3 | 4 | @tags Solid.Tag.default_tags() 5 | |> Map.put("current_line", CustomTags.CurrentLine) 6 | 7 | defp render(template) do 8 | template 9 | |> Solid.parse!(tags: @tags) 10 | |> Solid.render!(%{}) 11 | |> IO.iodata_to_binary() 12 | end 13 | 14 | describe "line number processing" do 15 | test "text" do 16 | template = """ 17 | text 18 | {% current_line %} 19 | text 20 | """ 21 | 22 | assert render(template) == 23 | """ 24 | text 25 | 2 26 | text 27 | """ 28 | end 29 | 30 | test "comment" do 31 | template = """ 32 | {% comment %} {% assign x = 1 %} {% endcomment -%} 33 | {% current_line %} 34 | """ 35 | 36 | assert render(template) == 37 | """ 38 | 2 39 | """ 40 | end 41 | 42 | test "raw" do 43 | template = """ 44 | {% raw %}{% assign x = 1 %}{% endraw %} 45 | {% current_line %} 46 | """ 47 | 48 | assert render(template) == 49 | """ 50 | {% assign x = 1 %} 51 | 2 52 | """ 53 | end 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /lib/solid/tags/assign_tag.ex: -------------------------------------------------------------------------------- 1 | defmodule Solid.Tags.AssignTag do 2 | alias Solid.{Argument, Parser, Object} 3 | 4 | @type t :: %__MODULE__{ 5 | loc: Parser.Loc.t(), 6 | argument: Argument.t(), 7 | object: Object.t() 8 | } 9 | 10 | @enforce_keys [:loc, :argument, :object] 11 | defstruct [:loc, :argument, :object] 12 | 13 | @behaviour Solid.Tag 14 | 15 | @impl true 16 | def parse("assign", loc, context) do 17 | with {:ok, tokens, context} <- Solid.Lexer.tokenize_tag_end(context), 18 | {:ok, argument, tokens} <- Argument.parse(tokens), 19 | [{:assignment, _} | tokens] <- tokens, 20 | {:ok, object, [{:end, _}]} <- Solid.Object.parse(tokens) do 21 | {:ok, %__MODULE__{loc: loc, argument: argument, object: object}, context} 22 | else 23 | {:error, reason, _rest, loc} -> {:error, reason, loc} 24 | error -> error 25 | end 26 | end 27 | 28 | defimpl Solid.Renderable do 29 | def render(tag, context, options) do 30 | {:ok, new_value, context} = 31 | Solid.Argument.get(tag.object.argument, context, tag.object.filters, options) 32 | 33 | context = %{context | vars: Map.put(context.vars, to_string(tag.argument), new_value)} 34 | 35 | {[], context} 36 | end 37 | end 38 | 39 | defimpl Solid.Block do 40 | def blank?(_), do: true 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /test/solid/tags/comment_tag_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Solid.Tags.CommentTagTest do 2 | use ExUnit.Case, async: true 3 | alias Solid.Tags.CommentTag 4 | alias Solid.{Lexer, ParserContext} 5 | alias Solid.Parser.Loc 6 | 7 | defp parse(template) do 8 | context = %ParserContext{rest: template, line: 1, column: 1, mode: :normal} 9 | 10 | with {:ok, "comment", context} <- Lexer.tokenize_tag_start(context) do 11 | CommentTag.parse("comment", %Loc{line: 1, column: 1}, context) 12 | end 13 | end 14 | 15 | describe "parse/2" do 16 | test "basic" do 17 | template = ~s<{% comment %} {{ yo }} {% endcomment %}> 18 | 19 | assert parse(template) == 20 | {:ok, %CommentTag{loc: %Loc{line: 1, column: 1}}, 21 | %ParserContext{rest: "", line: 1, column: 40, mode: :normal}} 22 | end 23 | 24 | test "error" do 25 | template = ~s<{% comment %}> 26 | assert parse(template) == {:error, "Comment tag not terminated", %{column: 14, line: 1}} 27 | end 28 | end 29 | 30 | describe "Renderable impl" do 31 | test "does nothing" do 32 | template = ~s<{% comment %} {{ yo }} comment {% endcomment %}> 33 | context = %Solid.Context{} 34 | 35 | {:ok, tag, _rest} = parse(template) 36 | 37 | assert Solid.Renderable.render(tag, context, []) == {[], %Solid.Context{}} 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /lib/solid/tags/capture_tag.ex: -------------------------------------------------------------------------------- 1 | defmodule Solid.Tags.CaptureTag do 2 | alias Solid.{Argument, Parser} 3 | 4 | @type t :: %__MODULE__{ 5 | loc: Parser.Loc.t(), 6 | argument: Argument.t(), 7 | body: [Parser.entry()] 8 | } 9 | 10 | @enforce_keys [:loc, :argument, :body] 11 | defstruct [:loc, :argument, :body] 12 | 13 | @behaviour Solid.Tag 14 | 15 | @impl true 16 | def parse("capture", loc, context) do 17 | with {:ok, tokens, context} <- Solid.Lexer.tokenize_tag_end(context), 18 | {:ok, argument, [{:end, _}]} <- Argument.parse(tokens), 19 | {:ok, body, _tag, _tokens, context} <- 20 | Parser.parse_until(context, "endcapture", "Expected endcapture") do 21 | {:ok, %__MODULE__{loc: loc, argument: argument, body: body}, context} 22 | else 23 | {:ok, _, tokens} -> {:error, "Unexpected token", Parser.meta_head(tokens)} 24 | error -> error 25 | end 26 | end 27 | 28 | defimpl Solid.Renderable do 29 | def render(tag, context, options) do 30 | {captured, context} = Solid.render(tag.body, context, options) 31 | 32 | context = %{ 33 | context 34 | | vars: Map.put(context.vars, to_string(tag.argument), IO.iodata_to_binary(captured)) 35 | } 36 | 37 | {[], context} 38 | end 39 | end 40 | 41 | defimpl Solid.Block do 42 | def blank?(_), do: true 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /test/solid/integration/whitespace/case_tag_test.exs: -------------------------------------------------------------------------------- 1 | import WhitespaceTrimHelper 2 | 3 | test_permutations "case tag evaluated to when body", 4 | ~s({ "shipping_method" : { "title" : "Local Pick-Up" } }) do 5 | """ 6 | ######################## 7 | 8 | {% case shipping_method.title %} 9 | 10 | {% when 'Local Pick-Up' %} 11 | 12 | Your order will be ready for pick-up tomorrow. 13 | 14 | {% else %} 15 | 16 | Thank you for your order! 17 | 18 | {% endcase %} 19 | 20 | ######################## 21 | """ 22 | end 23 | 24 | test_permutations "case tag evaluated to else body", 25 | ~s({ "shipping_method" : { "title" : "Something else" } }) do 26 | """ 27 | ######################## 28 | 29 | {% case shipping_method.title %} 30 | 31 | {% when 'Local Pick-Up' %} 32 | 33 | Your order will be ready for pick-up tomorrow. 34 | 35 | {% else %} 36 | 37 | Thank you for your order! 38 | 39 | {% endcase %} 40 | 41 | ######################## 42 | """ 43 | end 44 | 45 | test_permutations "case tag evaluated to nothing", 46 | ~s({ "shipping_method" : { "title" : "Something else" } }) do 47 | """ 48 | ######################## 49 | 50 | {% case shipping_method.title %} 51 | 52 | {% when 'Local Pick-Up' %} 53 | 54 | Your order will be ready for pick-up tomorrow. 55 | 56 | {% endcase %} 57 | 58 | ######################## 59 | """ 60 | end 61 | -------------------------------------------------------------------------------- /test/solid/tags/break_tag_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Solid.Tags.BreakTagTest do 2 | use ExUnit.Case, async: true 3 | alias Solid.Tags.BreakTag 4 | alias Solid.{Lexer, ParserContext} 5 | 6 | defp parse(template) do 7 | context = %ParserContext{rest: template, line: 1, column: 1, mode: :normal} 8 | 9 | with {:ok, "break", context} <- Lexer.tokenize_tag_start(context) do 10 | BreakTag.parse("break", %{line: 1, column: 1}, context) 11 | end 12 | end 13 | 14 | describe "parse/2" do 15 | test "basic" do 16 | template = ~s<{% break %}> 17 | 18 | assert parse(template) == { 19 | :ok, 20 | %BreakTag{loc: %{column: 1, line: 1}}, 21 | %ParserContext{column: 12, line: 1, mode: :normal, rest: ""} 22 | } 23 | end 24 | 25 | test "error" do 26 | template = ~s<{% break yo %}> 27 | assert parse(template) == {:error, "Unexpected token", %{column: 10, line: 1}} 28 | end 29 | 30 | test "unexpected character" do 31 | template = ~s<{% break - %}> 32 | assert parse(template) == {:error, "Unexpected character '-'", %{column: 10, line: 1}} 33 | end 34 | end 35 | 36 | describe "Renderable impl" do 37 | test "break exp" do 38 | template = ~s<{% break %}> 39 | context = %Solid.Context{} 40 | 41 | {:ok, tag, _rest} = parse(template) 42 | 43 | assert catch_throw(Solid.Renderable.render(tag, context, [])) == 44 | {:break_exp, [], context} 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /lib/solid/object.ex: -------------------------------------------------------------------------------- 1 | defmodule Solid.Object do 2 | @enforce_keys [:loc, :argument, :filters] 3 | defstruct [:loc, :argument, :filters] 4 | alias Solid.Parser.Loc 5 | alias Solid.{Argument, Filter, Lexer} 6 | 7 | @type t :: %__MODULE__{loc: Loc.t(), argument: Argument.t(), filters: [Filter]} 8 | 9 | @spec parse(Lexer.tokens()) :: {:ok, t, Lexer.tokens()} | {:error, binary, Lexer.loc()} 10 | def parse([{:end, meta}]) do 11 | # Let's use a null literal if the object is empty 12 | argument = %Solid.Literal{value: nil, loc: struct!(Loc, meta)} 13 | object = %__MODULE__{loc: struct!(Loc, meta), argument: argument, filters: []} 14 | {:ok, object, [{:end, meta}]} 15 | end 16 | 17 | def parse(tokens) do 18 | with {:ok, argument, filters, [{:end, _}] = rest} <- Argument.parse_with_filters(tokens) do 19 | object = 20 | %__MODULE__{ 21 | loc: struct!(Loc, Solid.Parser.meta_head(tokens)), 22 | argument: argument, 23 | filters: filters 24 | } 25 | 26 | {:ok, object, rest} 27 | else 28 | {:ok, _argument, _filters, rest} -> 29 | {:error, "Unexpected token", Solid.Parser.meta_head(rest)} 30 | 31 | {:error, reason, meta} -> 32 | {:error, reason, meta} 33 | end 34 | end 35 | 36 | defimpl Solid.Renderable do 37 | def render(object, context, options) do 38 | {:ok, result, context} = 39 | Solid.Argument.render(object.argument, context, object.filters, options) 40 | 41 | {result, context} 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /test/solid/integration/whitespace/if_tag_test.exs: -------------------------------------------------------------------------------- 1 | import WhitespaceTrimHelper 2 | 3 | test_permutations "if tag evaluated to if body" do 4 | """ 5 | ######################## 6 | 7 | {{ ' I am a object' }} 8 | {% if true %} 9 | 10 | I'm a if-body 11 | 12 | {% elsif false %} 13 | 14 | I'm a elsif-body 15 | 16 | {% else %} 17 | 18 | I'm a else-body 19 | 20 | {% endif %} 21 | 22 | ######################## 23 | """ 24 | end 25 | 26 | test_permutations "if tag evaluated to elsif body" do 27 | """ 28 | ######################## 29 | 30 | {{ ' I am a object' }} 31 | {% if false %} 32 | 33 | I'm a if-body 34 | 35 | {% elsif true %} 36 | 37 | I'm a elsif-body 38 | 39 | {% else %} 40 | 41 | I'm a else-body 42 | 43 | {% endif %} 44 | 45 | ######################## 46 | """ 47 | end 48 | 49 | test_permutations "if tag evaluated to else body" do 50 | """ 51 | ######################## 52 | 53 | {{ ' I am a object' }} 54 | {% if false %} 55 | 56 | I'm a if-body 57 | 58 | {% elsif false %} 59 | 60 | I'm a elsif-body 61 | 62 | {% else %} 63 | 64 | I'm a else-body 65 | 66 | {% endif %} 67 | 68 | ######################## 69 | """ 70 | end 71 | 72 | test_permutations "if tag evaluated to nothing" do 73 | """ 74 | ######################## 75 | 76 | {{ ' I am a object' }} 77 | {% if false %} 78 | 79 | I'm a if-body 80 | 81 | {% elsif false %} 82 | 83 | I'm a elsif-body 84 | 85 | {% endif %} 86 | 87 | ######################## 88 | """ 89 | end 90 | -------------------------------------------------------------------------------- /test/solid/tags/continue_tag_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Solid.Tags.ContinueTagTest do 2 | use ExUnit.Case, async: true 3 | alias Solid.Tags.ContinueTag 4 | alias Solid.{Lexer, ParserContext} 5 | alias Solid.Parser.Loc 6 | 7 | defp parse(template) do 8 | context = %ParserContext{rest: template, line: 1, column: 1, mode: :normal} 9 | 10 | with {:ok, "continue", context} <- Lexer.tokenize_tag_start(context) do 11 | ContinueTag.parse("continue", %Loc{line: 1, column: 1}, context) 12 | end 13 | end 14 | 15 | describe "parse/2" do 16 | test "basic" do 17 | template = ~s<{% continue %}> 18 | 19 | assert parse(template) == { 20 | :ok, 21 | %ContinueTag{loc: %Loc{line: 1, column: 1}}, 22 | %ParserContext{column: 15, line: 1, mode: :normal, rest: ""} 23 | } 24 | end 25 | 26 | test "error" do 27 | template = ~s<{% continue yo %}> 28 | assert parse(template) == {:error, "Unexpected token", %{column: 13, line: 1}} 29 | end 30 | 31 | test "unexpected character" do 32 | template = ~s<{% continue - %}> 33 | assert parse(template) == {:error, "Unexpected character '-'", %{column: 13, line: 1}} 34 | end 35 | end 36 | 37 | describe "Renderable impl" do 38 | test "continue exp" do 39 | template = ~s<{% continue %}> 40 | context = %Solid.Context{} 41 | 42 | {:ok, tag, _rest} = parse(template) 43 | 44 | assert catch_throw(Solid.Renderable.render(tag, context, [])) == 45 | {:continue_exp, [], context} 46 | end 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /test/solid/integration/whitespace/unless_tag_test.exs: -------------------------------------------------------------------------------- 1 | import WhitespaceTrimHelper 2 | 3 | test_permutations "unless tag evaluated to unless body" do 4 | """ 5 | ######################## 6 | 7 | {{ ' I am a object' }} 8 | {% unless false %} 9 | 10 | I'm a unless-body 11 | 12 | {% elsif false %} 13 | 14 | I'm a elsif-body 15 | 16 | {% else %} 17 | 18 | I'm a else-body 19 | 20 | {% endunless %} 21 | 22 | ######################## 23 | """ 24 | end 25 | 26 | test_permutations "unless tag evaluated to elsif body" do 27 | """ 28 | ######################## 29 | 30 | {{ ' I am a object' }} 31 | {% unless true %} 32 | 33 | I'm a unless-body 34 | 35 | {% elsif true %} 36 | 37 | I'm a elsif-body 38 | 39 | {% else %} 40 | 41 | I'm a else-body 42 | 43 | {% endunless %} 44 | 45 | ######################## 46 | """ 47 | end 48 | 49 | test_permutations "unless tag evaluated to else body" do 50 | """ 51 | ######################## 52 | 53 | {{ ' I am a object' }} 54 | {% unless true %} 55 | 56 | I'm a unless-body 57 | 58 | {% elsif false %} 59 | 60 | I'm a elsif-body 61 | 62 | {% else %} 63 | 64 | I'm a else-body 65 | 66 | {% endunless %} 67 | 68 | ######################## 69 | """ 70 | end 71 | 72 | test_permutations "unless tag evaluated to nothing" do 73 | """ 74 | ######################## 75 | 76 | {{ ' I am a object' }} 77 | {% unless true %} 78 | 79 | I'm a unless-body 80 | 81 | {% elsif false %} 82 | 83 | I'm a elsif-body 84 | 85 | {% endunless %} 86 | 87 | ######################## 88 | """ 89 | end 90 | -------------------------------------------------------------------------------- /test/solid/literal_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Solid.LiteralTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias Solid.Literal 5 | alias Solid.Parser.Loc 6 | 7 | defp parse(template) do 8 | context = %Solid.ParserContext{rest: "{{#{template}}}", line: 1, column: 1, mode: :normal} 9 | {:ok, tokens, _context} = Solid.Lexer.tokenize_object(context) 10 | Literal.parse(tokens) 11 | end 12 | 13 | describe "parse/1" do 14 | test "string literal" do 15 | template = "'a string'" 16 | 17 | assert parse(template) == { 18 | :ok, 19 | %Literal{loc: %Loc{column: 3, line: 1}, value: "a string"}, 20 | [{:end, %{line: 1, column: 13}}] 21 | } 22 | end 23 | 24 | test "number literal" do 25 | template = "0" 26 | 27 | assert parse(template) == { 28 | :ok, 29 | %Literal{loc: %Loc{column: 3, line: 1}, value: 0}, 30 | [{:end, %{line: 1, column: 4}}] 31 | } 32 | end 33 | 34 | test "negative number literal" do 35 | template = "-3" 36 | 37 | assert parse(template) == { 38 | :ok, 39 | %Literal{ 40 | loc: %Loc{column: 4, line: 1}, 41 | value: 3 42 | }, 43 | [end: %{column: 5, line: 1}] 44 | } 45 | end 46 | 47 | test "variable" do 48 | template = "var123 rest" 49 | 50 | assert parse(template) == {:error, "Literal expected", %{line: 1, column: 3}} 51 | end 52 | 53 | test "empty tokens" do 54 | assert parse("") == {:error, "Literal expected", %{line: 1, column: 3}} 55 | end 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /test/solid/integration/scenarios/object/input.liquid: -------------------------------------------------------------------------------- 1 | {{ foo }} 2 | 3 | {{ bar }} 4 | 5 | {{ var[1] }} 6 | {{ var }} 7 | 8 | {{ multiarray[0][1] }} 9 | {{ multiarray[1][1] }} 10 | {{ multiarray[12] }} 11 | {{ multiarray[-1][1] }}{{ empty[0][1] }} 12 | 13 | {{ empty }} 14 | {{ blank }} 15 | {{ nil }} 16 | {{ true }} 17 | {{ false }} 18 | 19 | {{ ['empty'] }} 20 | {{ ['blank'] }} 21 | {{ ['nil'] }} 22 | {{ ['true'] }} 23 | {{ ['false'] }} 24 | {{ ['foo'] }} 25 | {{ [foo] }}={{ ['bar'] }} 26 | {{ [numbers.zero] }} 27 | {{ [var[numbers.zero]] }} 28 | 29 | {{ empty[1] }} 30 | {{ blank[1] }} 31 | {{ nil[1] }} 32 | {{ true[1] }} 33 | {{ false[1] }} 34 | 35 | {{ string[0][1] }} 36 | {{ string[1][1] }} 37 | {{ string[12] }} 38 | 39 | {{ complexarray }} 40 | 41 | {{ list[1].key }} 42 | 43 | {{ "Have you read Ulysses?" | strip_html }} 44 | 45 | {{ "john@liquid.com" | url_encode }} 46 | {{ "Tetsuro Takara" | url_encode }} 47 | 48 | {{ "%27Stop%21%27+said+Fred" | url_decode }} 49 | 50 | {{ "Have you read 'James & the Giant Peach'?" | escape }} 51 | {{ "Tetsuro Takara" | escape }} 52 | 53 | {{ "1 < 2 & 3" | escape_once }} 54 | {{ "1 < 2 & 3" | escape_once }} 55 | 56 | {{ hash.key }} 57 | 58 | {{ 'text' | unknown_function: 'too many args' }} 59 | 60 | {{ (1..'2') }} 61 | {{ (1..foo) }} 62 | {{ (1..number) }} 63 | 64 | {{ question? }} 65 | 66 | {{ ['a var'].foo }} 67 | {{ deep['hash'].key }} 68 | 69 | {{ var.size }} 70 | {{ var.size.something }} 71 | {{ var.key }} 72 | {{ var.last }} 73 | {{ ['a var'].first | join: '#' }} 74 | {{ ['a var'].last }} 75 | {{ var.first }} 76 | 77 | {{ numbers.size }} 78 | {{ numbers.size.something }} 79 | {{ numbers.key }} 80 | 81 | {{ ['a var'].first }} 82 | 83 | -------------------------------------------------------------------------------- /lib/solid/tag.ex: -------------------------------------------------------------------------------- 1 | defmodule Solid.Tag do 2 | alias Solid.{Lexer, ParserContext, Renderable, Tags} 3 | alias Solid.Parser.Loc 4 | 5 | @callback parse( 6 | tag_name :: binary, 7 | Loc.t(), 8 | ParserContext.t() 9 | ) :: 10 | {:ok, Renderable.t(), ParserContext.t()} 11 | | {:error, reason :: binary, Lexer.loc()} 12 | | {:error, reason :: binary, rest :: binary, Lexer.loc()} 13 | 14 | def default_tags do 15 | %{ 16 | "#" => Tags.InlineCommentTag, 17 | "assign" => Tags.AssignTag, 18 | "break" => Tags.BreakTag, 19 | "capture" => Tags.CaptureTag, 20 | "case" => Tags.CaseTag, 21 | "comment" => Tags.CommentTag, 22 | "continue" => Tags.ContinueTag, 23 | "cycle" => Tags.CycleTag, 24 | "decrement" => Tags.CounterTag, 25 | "echo" => Tags.EchoTag, 26 | "for" => Tags.ForTag, 27 | "if" => Tags.IfTag, 28 | "increment" => Tags.CounterTag, 29 | "raw" => Tags.RawTag, 30 | "render" => Tags.RenderTag, 31 | "unless" => Tags.IfTag, 32 | "tablerow" => Tags.TablerowTag 33 | } 34 | end 35 | 36 | @spec parse(tag_name :: binary, Loc.t(), ParserContext.t()) :: 37 | {:ok, Renderable.t(), ParserContext.t()} 38 | | {:error, reason :: binary, Lexer.loc()} 39 | | {:error, reason :: binary, rest :: binary, Lexer.loc()} 40 | def parse(tag_name, loc, context) do 41 | module = (context.tags || default_tags())[tag_name] 42 | 43 | if module do 44 | module.parse(tag_name, loc, context) 45 | else 46 | {:error, "Unexpected tag '#{tag_name}'", %{line: loc.line, column: loc.column}} 47 | end 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | format: 7 | name: Format and compile with warnings as errors 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v2 11 | 12 | - name: Install OTP and Elixir 13 | uses: erlef/setup-beam@v1 14 | with: 15 | otp-version: 27.x 16 | elixir-version: 1.18.x 17 | 18 | - name: Install dependencies 19 | run: mix deps.get 20 | 21 | - name: Compile with --warnings-as-errors 22 | run: mix compile --warnings-as-errors 23 | 24 | - name: Run "mix format" 25 | run: mix format --check-formatted 26 | 27 | test: 28 | name: Test (Elixir ${{matrix.elixir}} | Erlang/OTP ${{matrix.otp}}) 29 | runs-on: ubuntu-latest 30 | strategy: 31 | fail-fast: false 32 | matrix: 33 | include: 34 | - otp: 27.x 35 | elixir: 1.17.x 36 | - otp: 27.x 37 | elixir: 1.18.x 38 | coverage: true 39 | env: 40 | MIX_ENV: test 41 | steps: 42 | - uses: actions/checkout@v2 43 | 44 | - name: Install OTP and Elixir 45 | uses: erlef/setup-beam@v1 46 | with: 47 | otp-version: ${{matrix.otp}} 48 | elixir-version: ${{matrix.elixir}} 49 | 50 | - name: Set up Ruby 51 | uses: ruby/setup-ruby@v1 52 | with: 53 | ruby-version: 3.1.0 54 | bundler: 2.3.23 55 | 56 | - name: Install dependencies 57 | run: mix deps.get --only test 58 | 59 | - name: Install liquid gem 60 | run: gem install liquid -v 5.8.1 61 | 62 | - name: Run tests 63 | run: mix test --trace --include integration 64 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Solid.MixProject do 2 | use Mix.Project 3 | 4 | @source_url "https://github.com/edgurgel/solid" 5 | @version "1.2.0" 6 | 7 | def project do 8 | [ 9 | app: :solid, 10 | version: @version, 11 | elixir: "~> 1.17", 12 | elixirc_paths: elixirc_paths(Mix.env()), 13 | consolidate_protocols: Mix.env() != :test, 14 | start_permanent: Mix.env() == :prod, 15 | name: "solid", 16 | package: package(), 17 | docs: docs(), 18 | deps: deps() 19 | ] 20 | end 21 | 22 | def application do 23 | [ 24 | extra_applications: [:logger] 25 | ] 26 | end 27 | 28 | # Run "mix help deps" to learn about dependencies. 29 | defp deps do 30 | [ 31 | {:decimal, "~> 2.0"}, 32 | {:date_time_parser, "~> 1.2"}, 33 | {:dialyxir, "~> 1.0", only: [:dev], runtime: false}, 34 | {:ex_doc, ">= 0.0.0", only: :dev, runtime: false}, 35 | {:jason, "~> 1.0", only: :test}, 36 | {:mix_test_watch, "~> 1.0", only: [:dev, :test], runtime: false} 37 | ] 38 | end 39 | 40 | defp package do 41 | [ 42 | description: "Liquid Template engine", 43 | maintainers: ["Eduardo Gurgel Pinho"], 44 | licenses: ["MIT"], 45 | links: %{"Github" => @source_url} 46 | ] 47 | end 48 | 49 | defp docs do 50 | [ 51 | extras: [ 52 | "CONTRIBUTING.md": [title: "Contributing"], 53 | "LICENSE.md": [title: "License"], 54 | "README.md": [title: "Overview"] 55 | ], 56 | main: "readme", 57 | source_url: @source_url, 58 | source_ref: "v#{@version}", 59 | formatters: ["html"] 60 | ] 61 | end 62 | 63 | defp elixirc_paths(:test), do: ["lib", "test/support"] 64 | defp elixirc_paths(_), do: ["lib"] 65 | end 66 | -------------------------------------------------------------------------------- /test/support/custom_tags.ex: -------------------------------------------------------------------------------- 1 | defmodule CustomTags do 2 | defmodule CurrentLine do 3 | @enforce_keys [:loc] 4 | defstruct [:loc] 5 | 6 | @behaviour Solid.Tag 7 | 8 | @impl true 9 | def parse("current_line", loc, context) do 10 | with {:ok, [{:end, _}], context} <- Solid.Lexer.tokenize_tag_end(context) do 11 | {:ok, %__MODULE__{loc: loc}, context} 12 | end 13 | end 14 | 15 | defimpl Solid.Renderable do 16 | def render(tag, context, _options) do 17 | {to_string(tag.loc.line), context} 18 | end 19 | end 20 | end 21 | 22 | defmodule CurrentYear do 23 | @enforce_keys [:loc] 24 | defstruct [:loc] 25 | 26 | @behaviour Solid.Tag 27 | 28 | @impl true 29 | def parse("get_current_year", loc, context) do 30 | with {:ok, [{:end, _}], context} <- Solid.Lexer.tokenize_tag_end(context) do 31 | {:ok, %__MODULE__{loc: loc}, context} 32 | end 33 | end 34 | 35 | defimpl Solid.Renderable do 36 | def render(_tag, context, _options) do 37 | {[to_string(Date.utc_today().year)], context} 38 | end 39 | end 40 | end 41 | 42 | defmodule CustomBrackedWrappedTag do 43 | alias Solid.Parser 44 | 45 | @enforce_keys [:loc, :body] 46 | defstruct [:loc, :body] 47 | @behaviour Solid.Tag 48 | 49 | @impl true 50 | def parse("myblock", loc, context) do 51 | with {:ok, [{:end, _}], context} <- Solid.Lexer.tokenize_tag_end(context), 52 | {:ok, body, _tag, _tokens, context} <- 53 | Parser.parse_until(context, "endmyblock", "Expected endmyblock") do 54 | {:ok, %__MODULE__{loc: loc, body: body}, context} 55 | end 56 | end 57 | 58 | defimpl Solid.Renderable do 59 | def render(tag, context, _options) do 60 | {tag.body, context} 61 | end 62 | end 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /lib/solid/tags/counter_tag.ex: -------------------------------------------------------------------------------- 1 | defmodule Solid.Tags.CounterTag do 2 | alias Solid.Argument 3 | 4 | @type t :: %__MODULE__{ 5 | loc: Solid.Parser.Loc.t(), 6 | argument: Argument.t(), 7 | operation: :increment | :decrement 8 | } 9 | 10 | @enforce_keys [:loc, :argument, :operation] 11 | defstruct [:loc, :argument, :operation] 12 | 13 | @behaviour Solid.Tag 14 | 15 | @impl true 16 | def parse(counter, loc, context) when counter in ~w(increment decrement) do 17 | with {:ok, tokens, context} <- Solid.Lexer.tokenize_tag_end(context), 18 | {:ok, argument, [{:end, _}]} <- Argument.parse(tokens) do 19 | {:ok, %__MODULE__{loc: loc, argument: argument, operation: String.to_atom(counter)}, 20 | context} 21 | else 22 | {:ok, _var, rest} -> 23 | {:error, "Unexpected token after argument", Solid.Parser.meta_head(rest)} 24 | 25 | {:error, reason, _, loc} -> 26 | {:error, reason, loc} 27 | 28 | {:error, reason, loc} -> 29 | {:error, reason, loc} 30 | end 31 | end 32 | 33 | defimpl Solid.Renderable do 34 | def render(%Solid.Tags.CounterTag{operation: :increment} = tag, context, _options) do 35 | key = to_string(tag.argument) 36 | value = Solid.Context.get_counter(context, [key]) 37 | 38 | value = value || 0 39 | 40 | context = %{context | counter_vars: Map.put(context.counter_vars, key, value + 1)} 41 | 42 | {[to_string(value)], context} 43 | end 44 | 45 | def render(%Solid.Tags.CounterTag{operation: :decrement} = tag, context, _options) do 46 | key = to_string(tag.argument) 47 | value = Solid.Context.get_counter(context, [key]) 48 | 49 | value = (value || 0) - 1 50 | 51 | context = %{context | counter_vars: Map.put(context.counter_vars, key, value)} 52 | 53 | {[to_string(value)], context} 54 | end 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /lib/solid/tags/cycle_tag.ex: -------------------------------------------------------------------------------- 1 | defmodule Solid.Tags.CycleTag do 2 | alias Solid.Argument 3 | 4 | @type t :: %__MODULE__{ 5 | loc: Solid.Parser.Loc.t(), 6 | values: [Argument.t()], 7 | name: Argument.t() | nil 8 | } 9 | 10 | @enforce_keys [:loc, :values, :name] 11 | defstruct [:loc, :values, :name] 12 | 13 | @behaviour Solid.Tag 14 | 15 | @impl true 16 | def parse("cycle", loc, context) do 17 | with {:ok, tokens, context} <- Solid.Lexer.tokenize_tag_end(context), 18 | {:ok, name, tokens} <- parse_name(tokens), 19 | {:ok, values, [{:end, _}]} <- parse_values(tokens) do 20 | {:ok, %__MODULE__{loc: loc, values: values, name: name}, context} 21 | else 22 | {:error, reason, _rest, loc} -> {:error, reason, loc} 23 | error -> error 24 | end 25 | end 26 | 27 | defp parse_name(tokens) do 28 | with {:ok, argument, tokens} <- Argument.parse(tokens), 29 | [{:colon, _} | rest] <- tokens do 30 | {:ok, argument, rest} 31 | else 32 | _ -> {:ok, nil, tokens} 33 | end 34 | end 35 | 36 | defp parse_values(tokens, acc \\ []) do 37 | with {:ok, argument, tokens} <- Argument.parse(tokens) do 38 | case tokens do 39 | [{:end, _}] -> 40 | {:ok, Enum.reverse([argument | acc]), tokens} 41 | 42 | [{:comma, _} | rest] -> 43 | parse_values(rest, [argument | acc]) 44 | 45 | _ -> 46 | {:error, "Expected end or comma", Solid.Parser.meta_head(tokens)} 47 | end 48 | end 49 | end 50 | 51 | defimpl Solid.Renderable do 52 | def render(tag, context, options) do 53 | {context, result} = Solid.Context.run_cycle(context, tag.name, tag.values) 54 | 55 | if result do 56 | {:ok, value, context} = Argument.get(result, context, [], options) 57 | {[to_string(value)], context} 58 | else 59 | {[], context} 60 | end 61 | end 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /test/solid/integration/scenarios/case/input.liquid: -------------------------------------------------------------------------------- 1 | {% case shipping_method.title %} 2 | {% when 'International Shipping' %} 3 | You're shipping internationally. Your order should arrive in 2–3 weeks. 4 | {% when 'Domestic Shipping' %} 5 | Your order should arrive in 3–4 days. 6 | {% when 'Local Pick-Up' %} 7 | Your order will be ready for pick-up tomorrow. 8 | {% else %} 9 | Thank you for your order! 10 | {% endcase %} 11 | 12 | "Blank" tags inside when 13 | {% case 'x' %}{% when 'x' %} {% assign y = 1 %} {% endcase %} 14 | "Blank" tags inside else 15 | {% case 'x' %}{% else %} {% assign y = 1 %} {% endcase %} 16 | 17 | {% case title %}{% endcase %} 18 | {% case title %}{% when other %}foo{% when 'other' %}bar{% else %} else {% endcase %} 19 | {% case title %}{% when other %}foo{% else %} else {% when 'other' %}bar{% endcase %} 20 | {% case title %}{% when 'wont match' %}nope{% else %} else {% endcase %} 21 | {% case multiple_elses %} {% else %} else1 {% when 'wont match' %}nope{% else %} else2 {% else %} else3 {% endcase %} 22 | {% case title %}{% when 'wont match' %}{% else %} else {% endcase %} 23 | {% case title %}{% when 'ABC' %}{% else %} else {% endcase %} 24 | 25 | {% case title %}{% when 'wont match' %}foo{% else %}bar{% else %}baz{% when 'ABC' %}match!{% endcase %} 26 | 27 | {% case title %}{% when 'wont match' %}foo{% else %}bar{% else %}baz{% when 'ABC' %}match! {% else %}baba{% endcase %} 28 | 29 | {% case 'not a variable' %}{% when 'ABC' %}{% else %} else {% endcase %} 30 | 31 | {% case product -%} {%- when 'shoes' -%} Shoes {%- when 'shoes' -%} Shoes also {%- endcase -%} 32 | 33 | {% case condition %}{% when 1, 2, 3 %} its 1 or 2 or 3 {% when 3, 4 %} its 4 {% endcase %} 34 | 35 | {% case condition %}{% when 1 or 2 or 3 %} its 1 or 2 or 3 {% when 4 %} its 4 {% endcase %} 36 | 37 | {% case condition %}{% when 1 or 2 or 3 or 3 %} its 1 or 2 or 3 {% when 4 %} its 4 {% endcase %} 38 | 39 | {%- case condition -%}{%- when 1 or 2 or 3 or 3 -%}{%- when 4 -%}{%- endcase %} 40 | -------------------------------------------------------------------------------- /test/solid/binary_condition_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Solid.BinaryConditionTest do 2 | use ExUnit.Case, async: true 3 | 4 | import Solid.BinaryCondition 5 | 6 | describe "eval/2" do 7 | test "numbers and comparison operators" do 8 | assert eval({1, :==, 1}) == {:ok, true} 9 | assert eval({1, :!=, 2}) == {:ok, true} 10 | assert eval({1, :<>, 2}) == {:ok, true} 11 | assert eval({1, :<, 2}) == {:ok, true} 12 | assert eval({2, :>, 1}) == {:ok, true} 13 | assert eval({1, :>=, 1}) == {:ok, true} 14 | assert eval({2, :>=, 1}) == {:ok, true} 15 | assert eval({1, :<=, 2}) == {:ok, true} 16 | assert eval({1, :<=, 1}) == {:ok, true} 17 | assert eval({1, :>, -2}) == {:ok, true} 18 | assert eval({-2, :<, 2}) == {:ok, true} 19 | assert eval({1.0, :>, -1.0}) == {:ok, true} 20 | assert eval({-1.0, :<, 1.0}) == {:ok, true} 21 | 22 | assert eval({1, :==, 2}) == {:ok, false} 23 | assert eval({1, :!=, 1}) == {:ok, false} 24 | assert eval({1, :<>, 1}) == {:ok, false} 25 | assert eval({1, :<, 0}) == {:ok, false} 26 | assert eval({2, :>, 4}) == {:ok, false} 27 | assert eval({1, :>=, 3}) == {:ok, false} 28 | assert eval({2, :>=, 4}) == {:ok, false} 29 | assert eval({1, :<=, 0}) == {:ok, false} 30 | end 31 | 32 | test "contains" do 33 | assert eval({"jose", :contains, "o"}) == {:ok, true} 34 | assert eval({"jose", :contains, "jose"}) == {:ok, true} 35 | 36 | assert eval({"jose", :contains, "john"}) == {:ok, false} 37 | end 38 | 39 | test "number and string" do 40 | assert eval({1, :<, "jose"}) == {:error, "comparison of Integer with String failed"} 41 | assert eval({"jose", :<, 1}) == {:error, "comparison of String with 1 failed"} 42 | 43 | assert eval({1, :==, "jose"}) == {:ok, false} 44 | 45 | assert eval({1.0, :<, "jose"}) == {:error, "comparison of Float with String failed"} 46 | assert eval({"jose", :<, 1.0}) == {:ok, false} 47 | assert eval({1.0, :==, "jose"}) == {:ok, false} 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /test/solid/sigil_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Solid.SigilTest do 2 | use ExUnit.Case, async: true 3 | 4 | import Solid.Sigil 5 | 6 | describe "sigil_LIQUID/2" do 7 | test "compiles valid templates" do 8 | template = ~LIQUID"Hello, {{ name }}!" 9 | 10 | assert template == Solid.parse!("Hello, {{ name }}!") 11 | end 12 | 13 | test "raises CompileError for unclosed tag" do 14 | code = """ 15 | import Solid.Sigil 16 | ~LIQUID"Hello, {{ name!" 17 | """ 18 | 19 | assert_raise CompileError, ~r/Liquid template syntax error/, fn -> 20 | Code.eval_string(code) 21 | end 22 | end 23 | 24 | test "raises CompileError for invalid tag" do 25 | code = """ 26 | import Solid.Sigil 27 | ~LIQUID"{% invalid_tag %}" 28 | """ 29 | 30 | assert_raise CompileError, ~r/Liquid template syntax error/, fn -> 31 | Code.eval_string(code) 32 | end 33 | end 34 | 35 | test "raises CompileError for unbalanced tags" do 36 | code = """ 37 | import Solid.Sigil 38 | ~LIQUID"{% if condition %}No closing endif" 39 | """ 40 | 41 | assert_raise CompileError, ~r/Liquid template syntax error/, fn -> 42 | Code.eval_string(code) 43 | end 44 | end 45 | 46 | test "error message includes line number and contextual information" do 47 | code = """ 48 | import Solid.Sigil 49 | ~LIQUID\"\"\" 50 | Line 1 is fine 51 | Line 2 has {% invalid_tag %} an error 52 | Line 3 is also fine 53 | \"\"\" 54 | """ 55 | 56 | assert_raise CompileError, ~r/Line 2 has {% invalid_tag %} an error/, fn -> 57 | Code.eval_string(code) 58 | end 59 | end 60 | 61 | test "compiles templates with custom tags defined in @liquid_tags" do 62 | code = """ 63 | defmodule MyModule do 64 | import Solid.Sigil 65 | 66 | @liquid_tags Solid.Tag.default_tags() |> Map.put("current_line", CustomTags.CurrentLine) 67 | 68 | def template do 69 | ~LIQUID"{% current_line %}" 70 | end 71 | end 72 | """ 73 | 74 | assert {{:module, my_module, _, _}, _} = Code.eval_string(code) 75 | assert IO.iodata_to_binary(Solid.render!(my_module.template(), %{})) == "1" 76 | end 77 | end 78 | end 79 | -------------------------------------------------------------------------------- /test/solid/tags/echo_tag_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Solid.Tags.EchoTagTest do 2 | use ExUnit.Case, async: true 3 | alias Solid.Tags.EchoTag 4 | alias Solid.{Lexer, ParserContext} 5 | alias Solid.Parser.Loc 6 | 7 | defp parse(template) do 8 | context = %ParserContext{rest: template, line: 1, column: 1, mode: :normal} 9 | 10 | with {:ok, "echo", context} <- Lexer.tokenize_tag_start(context) do 11 | EchoTag.parse("echo", %Loc{line: 1, column: 1}, context) 12 | end 13 | end 14 | 15 | describe "parse/2" do 16 | test "basic" do 17 | template = ~s<{% echo "I am a tag" | upcase %}> 18 | 19 | assert parse(template) == 20 | { 21 | :ok, 22 | %EchoTag{ 23 | loc: %Loc{column: 1, line: 1}, 24 | object: %Solid.Object{ 25 | argument: %Solid.Literal{ 26 | loc: %Solid.Parser.Loc{column: 9, line: 1}, 27 | value: "I am a tag" 28 | }, 29 | filters: [ 30 | %Solid.Filter{ 31 | function: "upcase", 32 | loc: %Loc{column: 24, line: 1}, 33 | named_arguments: %{}, 34 | positional_arguments: [] 35 | } 36 | ], 37 | loc: %Loc{column: 9, line: 1} 38 | } 39 | }, 40 | %ParserContext{rest: "", line: 1, column: 33, mode: :normal} 41 | } 42 | end 43 | 44 | test "error" do 45 | template = ~s<{% echo | %}> 46 | assert parse(template) == {:error, "Argument expected", %{line: 1, column: 9}} 47 | end 48 | 49 | test "unexpected character" do 50 | template = ~s<{% echo - %}> 51 | assert parse(template) == {:error, "Unexpected character '-'", %{column: 9, line: 1}} 52 | end 53 | end 54 | 55 | describe "Renderable impl" do 56 | test "echo prints string" do 57 | template = ~s<{% echo "I am a tag" | upcase %}> 58 | context = %Solid.Context{} 59 | 60 | {:ok, tag, _rest} = parse(template) 61 | 62 | assert Solid.Renderable.render(tag, context, []) == {["I AM A TAG"], context} 63 | end 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /test/support/whitespace_trim_helper.ex: -------------------------------------------------------------------------------- 1 | defmodule WhitespaceTrimHelper do 2 | defmacro test_permutations(name, json \\ "{}", do: input) do 3 | sanitized_name = name |> String.split() |> Enum.map(&String.capitalize/1) |> Enum.join() 4 | 5 | module_name = 6 | Module.concat([Solid.Integration.WhitespaceTrimCase, :"#{sanitized_name}Test"]) 7 | 8 | quote do 9 | defmodule unquote(module_name) do 10 | use ExUnit.Case, async: true 11 | import Solid.Helpers 12 | import WhitespaceTrimHelper 13 | @moduletag :integration 14 | 15 | for {variant, counter} <- Enum.with_index(generate_permutations(unquote(input))) do 16 | @tag variant_nr: counter, variant: variant 17 | test "#{unquote(sanitized_name)}: #{counter}", %{variant: variant} do 18 | assert_render(variant, unquote(json), nil) 19 | end 20 | end 21 | end 22 | end 23 | end 24 | 25 | @tags ["{{", "}}", "{%", "%}"] 26 | 27 | def generate_permutations(template) do 28 | input = 29 | build_regex() 30 | |> Regex.split(template) 31 | 32 | versions = 33 | input 34 | |> find_tag_indexes() 35 | |> build_versions(input) 36 | 37 | (versions ++ [build_complete_version(input), input]) 38 | |> Enum.map(&List.to_string/1) 39 | end 40 | 41 | defp build_regex do 42 | tags_or = Enum.join(@tags, "|") 43 | ~r/(?=(#{tags_or}))|(?<=(#{tags_or}))/ 44 | end 45 | 46 | defp find_tag_indexes(list) do 47 | list 48 | |> Enum.with_index() 49 | |> Enum.filter(fn {item, _i} -> Enum.member?(@tags, item) end) 50 | |> Enum.map(fn {_item, i} -> i end) 51 | end 52 | 53 | defp build_versions(indexes, input) do 54 | Enum.map(indexes, fn i -> 55 | new_item = 56 | input 57 | |> Enum.at(i) 58 | |> to_trimming() 59 | 60 | List.replace_at(input, i, new_item) 61 | end) 62 | end 63 | 64 | defp build_complete_version(input) do 65 | Enum.map(input, fn item -> 66 | if Enum.member?(@tags, item) do 67 | to_trimming(item) 68 | else 69 | item 70 | end 71 | end) 72 | end 73 | 74 | defp to_trimming("{{"), do: "{{-" 75 | defp to_trimming("}}"), do: "-}}" 76 | defp to_trimming("{%"), do: "{%-" 77 | defp to_trimming("%}"), do: "-%}" 78 | end 79 | -------------------------------------------------------------------------------- /test/solid/tags/raw_tag_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Solid.Tags.RawTagTest do 2 | use ExUnit.Case, async: true 3 | alias Solid.Tags.RawTag 4 | alias Solid.{Lexer, ParserContext} 5 | alias Solid.Parser.Loc 6 | 7 | defp parse(template) do 8 | context = %ParserContext{rest: template, line: 1, column: 1, mode: :normal} 9 | 10 | with {:ok, tag_name, context} <- Lexer.tokenize_tag_start(context) do 11 | RawTag.parse(tag_name, %Loc{line: 1, column: 1}, context) 12 | end 13 | end 14 | 15 | describe "parse/2" do 16 | test "basic" do 17 | template = ~s<{% raw %} {{ yo }} {% endraw %}> 18 | 19 | assert parse(template) == 20 | { 21 | :ok, 22 | %RawTag{loc: %Loc{line: 1, column: 1}, text: " {{ yo }} "}, 23 | %Solid.ParserContext{rest: "", line: 1, column: 32, mode: :normal} 24 | } 25 | end 26 | 27 | test "basic with whitespace control" do 28 | template = ~s<{%- raw -%} {{ yo }} {%- endraw -%} > 29 | 30 | assert parse(template) == 31 | { 32 | :ok, 33 | %RawTag{loc: %Loc{column: 1, line: 1}, text: "{{ yo }}"}, 34 | %Solid.ParserContext{column: 37, line: 1, mode: :normal, rest: ""} 35 | } 36 | end 37 | 38 | test "raw tag not closed" do 39 | template = ~s<{% raw %} {{ yo }} {% end %}> 40 | 41 | assert parse(template) == 42 | {:error, "Raw tag not terminated", %{column: 29, line: 1}} 43 | end 44 | 45 | test "raw tag extra tokens" do 46 | template = ~s<{% raw arg1 %} {{ yo }} {% endraw %}> 47 | 48 | assert parse(template) == {:error, "Unexpected token", %{column: 8, line: 1}} 49 | end 50 | 51 | test "raw tag unexpected character" do 52 | template = ~s<{% raw arg1 - 1 %} {{ yo }} {% endraw %}> 53 | 54 | assert parse(template) == {:error, "Unexpected character '-'", %{column: 13, line: 1}} 55 | end 56 | end 57 | 58 | describe "Renderable impl" do 59 | test "raw tag prints everything inside" do 60 | template = ~s<{% raw %} {{ yo }} {% endraw %}> 61 | context = %Solid.Context{} 62 | 63 | {:ok, tag, _rest} = parse(template) 64 | 65 | assert Solid.Renderable.render(tag, context, []) == {" {{ yo }} ", context} 66 | end 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /lib/solid/tags/comment_tag.ex: -------------------------------------------------------------------------------- 1 | defmodule Solid.Tags.CommentTag do 2 | @enforce_keys [:loc] 3 | defstruct [:loc] 4 | 5 | @behaviour Solid.Tag 6 | 7 | @impl true 8 | def parse("comment", loc, context) do 9 | with {:ok, _tokens, context} <- Solid.Lexer.tokenize_tag_end(context), 10 | {:ok, context} <- ignore_body(context) do 11 | {:ok, %__MODULE__{loc: loc}, context} 12 | end 13 | end 14 | 15 | @whitespaces [" ", "\f", "\r", "\t", "\v"] 16 | 17 | defp ignore_body(context) do 18 | case context.rest do 19 | <<"\n", rest::binary>> -> 20 | if context.mode == :liquid_tag do 21 | case Solid.Parser.maybe_tokenize_tag("endcomment", context) do 22 | {:tag, _tag_name, _tokens, context} -> 23 | {:ok, context} 24 | 25 | {:not_found, context} -> 26 | ignore_body(%{context | rest: rest, line: context.line + 1, column: 1}) 27 | end 28 | else 29 | ignore_body(%{context | rest: rest, line: context.line + 1, column: 1}) 30 | end 31 | 32 | <> when c in @whitespaces -> 33 | ignore_body(%{context | rest: rest, column: context.column + 1}) 34 | 35 | <<"{%", rest::binary>> -> 36 | case Solid.Parser.maybe_tokenize_tag("endcomment", context) do 37 | {:tag, _tag_name, _tokens, context} -> 38 | {:ok, context} 39 | 40 | {:not_found, context} -> 41 | ignore_body(%{context | rest: rest, column: context.column + 2}) 42 | end 43 | 44 | "" -> 45 | {:error, "Comment tag not terminated", %{line: context.line, column: context.column}} 46 | 47 | "endcomment" <> _ -> 48 | if context.mode == :liquid_tag do 49 | {:tag, _, _, context} = Solid.Parser.maybe_tokenize_tag("endcomment", context) 50 | {:ok, context} 51 | else 52 | <<_c, rest::binary>> = context.rest 53 | ignore_body(%{context | rest: rest, line: context.line, column: context.column + 1}) 54 | end 55 | 56 | <<_c, rest::binary>> -> 57 | ignore_body(%{context | rest: rest, column: context.column + 1}) 58 | end 59 | end 60 | 61 | defimpl Solid.Renderable do 62 | def render(_tag, context, _options) do 63 | {[], context} 64 | end 65 | end 66 | 67 | defimpl Solid.Block do 68 | def blank?(_), do: true 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /test/solid/integration/scenarios/shopping-cart/input.liquid: -------------------------------------------------------------------------------- 1 | 7 | 8 |
9 | 10 | {% if cart.item_count == 0 %} 11 |

Your shopping cart is looking rather empty...

12 | {% else %} 13 |
14 | 15 |
16 | 17 |

You have {{ cart.item_count }} {{ cart.item_count | pluralize: 'product', 'products' }} in here!

18 | 19 | 48 | 49 |
50 | 51 |
52 | 53 |
54 | 55 | {% if additional_checkout_buttons %} 56 |
57 |

- or -

58 | {{ content_for_additional_checkout_buttons }} 59 |
60 | {% endif %} 61 | 62 |
63 | 64 | {% endif %} 65 | 66 |
67 | -------------------------------------------------------------------------------- /lib/solid/matcher.ex: -------------------------------------------------------------------------------- 1 | defprotocol Solid.Matcher do 2 | @fallback_to_any true 3 | @doc "Assigns context to values" 4 | def match(_, _) 5 | end 6 | 7 | defimpl Solid.Matcher, for: Any do 8 | def match(data, []), do: {:ok, data} 9 | 10 | def match(_, _), do: {:error, :not_found} 11 | end 12 | 13 | defimpl Solid.Matcher, for: List do 14 | def match(data, []), do: {:ok, data} 15 | 16 | def match(data, ["first"]), do: {:ok, Enum.at(data, 0)} 17 | def match(data, ["last"]), do: {:ok, Enum.at(data, -1)} 18 | def match(data, ["size"]), do: {:ok, Enum.count(data)} 19 | 20 | def match(data, [key | keys]) when is_integer(key) do 21 | case Enum.fetch(data, key) do 22 | {:ok, value} -> @protocol.match(value, keys) 23 | _ -> {:error, :not_found} 24 | end 25 | end 26 | 27 | def match(_data, _) do 28 | {:error, :not_found} 29 | end 30 | end 31 | 32 | defimpl Solid.Matcher, for: Map do 33 | def match(data, []) do 34 | {:ok, data} 35 | end 36 | 37 | # Maps are not ordered so these are here just for consistency with the Liquid implementation 38 | # as we must return something 39 | 40 | def match(data, [key | keys]) do 41 | case Map.fetch(data, key) do 42 | {:ok, value} -> 43 | @protocol.match(value, keys) 44 | 45 | _ -> 46 | # Check if the key is a special case 47 | case key do 48 | "first" -> @protocol.match(Enum.at(data, 0), keys) 49 | "size" -> @protocol.match(map_size(data), keys) 50 | _ -> {:error, :not_found} 51 | end 52 | end 53 | end 54 | end 55 | 56 | defimpl Solid.Matcher, for: BitString do 57 | def match(current, []), do: {:ok, current} 58 | 59 | def match(data, ["size"]) do 60 | {:ok, String.length(data)} 61 | end 62 | 63 | def match(_data, [i | _]) when is_integer(i) do 64 | {:error, :not_found} 65 | end 66 | 67 | def match(_data, [i | _]) when is_binary(i) do 68 | {:error, :not_found} 69 | end 70 | end 71 | 72 | defimpl Solid.Matcher, for: Atom do 73 | def match(current, []) when is_nil(current), do: {:ok, nil} 74 | def match(data, []), do: {:ok, data} 75 | def match(nil, _), do: {:error, :not_found} 76 | 77 | @doc """ 78 | Matches all remaining cases 79 | """ 80 | def match(_current, [key]) when is_binary(key), do: {:error, :not_found} 81 | end 82 | 83 | defimpl Solid.Matcher, for: Tuple do 84 | def match(data, []), do: {:ok, data} 85 | 86 | def match(data, ["size"]) do 87 | {:ok, tuple_size(data)} 88 | end 89 | 90 | def match(data, [key | keys]) when is_integer(key) do 91 | try do 92 | elem(data, key) 93 | |> @protocol.match(keys) 94 | rescue 95 | ArgumentError -> {:error, :not_found} 96 | end 97 | end 98 | end 99 | -------------------------------------------------------------------------------- /lib/solid/tags/inline_comment_tag.ex: -------------------------------------------------------------------------------- 1 | defmodule Solid.Tags.InlineCommentTag do 2 | alias Solid.ParserContext 3 | 4 | @enforce_keys [:loc] 5 | defstruct [:loc] 6 | 7 | @behaviour Solid.Tag 8 | 9 | @impl true 10 | def parse("#", loc, context) do 11 | with {:ok, context} <- ignore_body(context) do 12 | {:ok, %__MODULE__{loc: loc}, context} 13 | end 14 | end 15 | 16 | @whitespaces [" ", "\f", "\r", "\t", "\v"] 17 | 18 | defp ignore_body(%ParserContext{mode: :liquid_tag} = context) do 19 | case context.rest do 20 | <<"\n", rest::binary>> -> 21 | {:ok, %{context | rest: rest, line: context.line + 1, column: 1}} 22 | 23 | <<_c, rest::binary>> -> 24 | ignore_body(%{context | rest: rest, column: context.column + 1}) 25 | end 26 | end 27 | 28 | defp ignore_body(%ParserContext{mode: :normal} = context) do 29 | case context.rest do 30 | <<"%}", rest::binary>> -> 31 | {:ok, %{context | rest: rest, column: context.column + 2}} 32 | 33 | <<"\n", rest::binary>> -> 34 | context = 35 | ignore_whitespace_and_new_line(%{ 36 | context 37 | | rest: rest, 38 | line: context.line + 1, 39 | column: 1 40 | }) 41 | 42 | case context.rest do 43 | <<"#", rest::binary>> -> 44 | ignore_body(%{context | rest: rest, column: context.column + 1}) 45 | 46 | <<"%}", rest::binary>> -> 47 | {:ok, %{context | rest: rest, column: context.column + 2}} 48 | 49 | <<"-%}", rest::binary>> -> 50 | context = 51 | ignore_whitespace_and_new_line(%{context | rest: rest, column: context.column + 3}) 52 | 53 | {:ok, context} 54 | 55 | _ -> 56 | {:error, "Syntax error in tag '#' - Each line of comments must be prefixed by '#'", 57 | %{line: context.line, column: context.column}} 58 | end 59 | 60 | <<_c, rest::binary>> -> 61 | ignore_body(%{context | rest: rest, column: context.column + 1}) 62 | end 63 | end 64 | 65 | defp ignore_whitespace_and_new_line(context) do 66 | case context.rest do 67 | <> when c in @whitespaces -> 68 | ignore_whitespace_and_new_line(%{context | rest: rest, column: context.column + 1}) 69 | 70 | <<"\n", rest::binary>> -> 71 | ignore_whitespace_and_new_line(%{context | rest: rest, line: context.line + 1, column: 1}) 72 | 73 | _ -> 74 | context 75 | end 76 | end 77 | 78 | defimpl Solid.Renderable do 79 | def render(_tag, context, _options) do 80 | {[], context} 81 | end 82 | end 83 | 84 | defimpl Solid.Block do 85 | def blank?(_), do: true 86 | end 87 | end 88 | -------------------------------------------------------------------------------- /test/support/helpers.ex: -------------------------------------------------------------------------------- 1 | defmodule Solid.Helpers do 2 | def render(text, hash \\ %{}, options \\ []) do 3 | case Solid.parse(text, options) do 4 | {:ok, template} -> 5 | template 6 | |> Solid.render(hash, options) 7 | |> case do 8 | {:error, errors, result} -> {:error, errors, to_string(result)} 9 | {:ok, result, _errors} -> to_string(result) 10 | end 11 | 12 | {:error, error} -> 13 | inspect(error) 14 | end 15 | rescue 16 | e -> 17 | IO.puts(Exception.format(:error, e, __STACKTRACE__)) 18 | inspect(e) 19 | end 20 | 21 | def integration_render(text, hash \\ %{}, options \\ []) do 22 | case Solid.parse(text, options) do 23 | {:ok, template} -> 24 | template 25 | |> Solid.render(hash, options) 26 | |> case do 27 | # Print whatever we got as result discarding errors 28 | {:error, _errors, result} -> to_string(result) 29 | {:ok, result, _errors} -> to_string(result) 30 | end 31 | 32 | {:error, error} -> 33 | inspect(error) 34 | end 35 | rescue 36 | e -> 37 | IO.puts(Exception.format(:error, e, __STACKTRACE__)) 38 | inspect(e) 39 | end 40 | 41 | def liquid_render(input_liquid, input_json, template_dir) do 42 | if template_dir do 43 | System.cmd("ruby", ["test/liquid.rb", input_liquid, input_json, template_dir]) 44 | else 45 | System.cmd("ruby", ["test/liquid.rb", input_liquid, input_json]) 46 | end 47 | end 48 | 49 | defmacro assert_render(liquid_input, json_input, template_dir, opts \\ []) do 50 | quote location: :keep do 51 | opts = 52 | if unquote(template_dir) do 53 | file_system = Solid.LocalFileSystem.new(unquote(template_dir)) 54 | [{:file_system, {Solid.LocalFileSystem, file_system}} | unquote(opts)] 55 | else 56 | unquote(opts) 57 | end 58 | 59 | solid_output = 60 | integration_render(unquote(liquid_input), Jason.decode!(unquote(json_input)), opts) 61 | |> IO.iodata_to_binary() 62 | 63 | {liquid_output, 0} = 64 | liquid_render(unquote(liquid_input), unquote(json_input), unquote(template_dir)) 65 | 66 | if liquid_output == solid_output do 67 | true 68 | else 69 | message = """ 70 | Render result was different! 71 | Input: 72 | #{unquote(liquid_input)} 73 | """ 74 | 75 | expr = 76 | quote do 77 | liquid_output == solid_output 78 | end 79 | 80 | raise ExUnit.AssertionError, 81 | expr: expr, 82 | left: liquid_output, 83 | right: solid_output, 84 | message: message 85 | end 86 | end 87 | end 88 | end 89 | -------------------------------------------------------------------------------- /test/solid/standard_filter_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Solid.StandardFilterTest do 2 | use ExUnit.Case, async: true 3 | alias Solid.StandardFilter 4 | doctest Solid.StandardFilter 5 | 6 | @loc %Solid.Parser.Loc{line: 1, column: 1} 7 | 8 | describe "apply/4" do 9 | test "basic filter" do 10 | assert StandardFilter.apply("upcase", ["ac"], @loc, []) == {:ok, "AC"} 11 | end 12 | 13 | test "argument error" do 14 | assert StandardFilter.apply("base64_url_safe_decode", [1], @loc, []) == { 15 | :error, 16 | %Solid.ArgumentError{ 17 | message: "invalid base64 provided to base64_url_safe_decode", 18 | loc: @loc 19 | } 20 | } 21 | end 22 | 23 | test "wrong arity" do 24 | assert StandardFilter.apply("upcase", ["ac", "extra", "arg"], @loc, []) == { 25 | :error, 26 | %Solid.WrongFilterArityError{ 27 | filter: :upcase, 28 | expected_arity: 1, 29 | arity: 3, 30 | loc: @loc 31 | } 32 | } 33 | end 34 | 35 | test "filter not found" do 36 | assert StandardFilter.apply("no_filter_here", [1, 2, 3], @loc, []) == {:ok, 1} 37 | end 38 | 39 | test "filter not found with strict_filters" do 40 | assert StandardFilter.apply("no_filter_here", [1, 2, 3], @loc, strict_filters: true) == 41 | { 42 | :error, 43 | %Solid.UndefinedFilterError{filter: "no_filter_here", loc: @loc}, 44 | 1 45 | } 46 | end 47 | end 48 | 49 | test "it applies a function as custom filter" do 50 | user_locale = "en" 51 | 52 | custom_filters = fn 53 | "format_number", [num] -> 54 | {:ok, "#{user_locale}: #{num}"} 55 | 56 | "format_number", [num, locale] -> 57 | {:ok, "#{locale}: #{num}"} 58 | 59 | _, _ -> 60 | :error 61 | end 62 | 63 | assert "en: 41" == 64 | "{{ number | format_number }}" 65 | |> Solid.parse!() 66 | |> Solid.render!(%{"number" => 41}, custom_filters: custom_filters) 67 | |> to_string() 68 | 69 | assert "fr: 41" == 70 | "{{ number | format_number: 'fr' }}" 71 | |> Solid.parse!() 72 | |> Solid.render!(%{"number" => 41}, custom_filters: custom_filters) 73 | |> to_string() 74 | end 75 | 76 | test "custom filter throw undefined error" do 77 | assert ["41"] == 78 | "{{ number | format_number }}" 79 | |> Solid.parse!() 80 | |> Solid.render!(%{"number" => 41}, 81 | custom_filters: fn _func, _args -> 82 | apply(Map, :does_not_exist, []) 83 | end 84 | ) 85 | end 86 | end 87 | -------------------------------------------------------------------------------- /test/solid/range_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Solid.RangeTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias Solid.{ParserContext, Range} 5 | alias Solid.Parser.Loc 6 | 7 | defp parse(template) do 8 | context = %ParserContext{rest: "{{#{template}}}", line: 1, column: 1, mode: :normal} 9 | {:ok, tokens, _context} = Solid.Lexer.tokenize_object(context) 10 | Range.parse(tokens) 11 | end 12 | 13 | @loc %Loc{line: 1, column: 1} 14 | 15 | describe "String.Chars impl" do 16 | test "to_string variables" do 17 | range = %Range{ 18 | loc: @loc, 19 | start: %Solid.Variable{ 20 | original_name: "first", 21 | loc: @loc, 22 | accesses: [], 23 | identifier: "first" 24 | }, 25 | finish: %Solid.Variable{ 26 | original_name: "limit", 27 | accesses: [], 28 | identifier: "limit", 29 | loc: @loc 30 | } 31 | } 32 | 33 | assert to_string(range) == "(first..limit)" 34 | end 35 | 36 | test "to_string literals" do 37 | range = %Range{ 38 | loc: @loc, 39 | start: %Solid.Literal{loc: @loc, value: 1}, 40 | finish: %Solid.Literal{value: 2, loc: @loc} 41 | } 42 | 43 | assert to_string(range) == "(1..2)" 44 | end 45 | end 46 | 47 | describe "parse/1" do 48 | test "range" do 49 | template = "(first..limit)" 50 | 51 | assert parse(template) == { 52 | :ok, 53 | %Solid.Range{ 54 | finish: %Solid.Variable{ 55 | original_name: "limit", 56 | accesses: [], 57 | identifier: "limit", 58 | loc: %Loc{column: 11, line: 1} 59 | }, 60 | loc: %Loc{column: 3, line: 1}, 61 | start: %Solid.Variable{ 62 | original_name: "first", 63 | loc: %Loc{column: 4, line: 1}, 64 | accesses: [], 65 | identifier: "first" 66 | } 67 | }, 68 | [end: %{column: 17, line: 1}] 69 | } 70 | end 71 | 72 | test "range literals" do 73 | template = "(1..5)" 74 | 75 | assert parse(template) == { 76 | :ok, 77 | %Solid.Range{ 78 | finish: %Solid.Literal{ 79 | loc: %Loc{column: 7, line: 1}, 80 | value: 5 81 | }, 82 | loc: %Loc{column: 3, line: 1}, 83 | start: %Solid.Literal{loc: %Loc{column: 4, line: 1}, value: 1} 84 | }, 85 | [end: %{column: 9, line: 1}] 86 | } 87 | end 88 | 89 | test "error" do 90 | template = "(1..15" 91 | 92 | assert parse(template) == {:error, "Range expected", %{line: 1, column: 3}} 93 | end 94 | end 95 | end 96 | -------------------------------------------------------------------------------- /lib/solid/binary_condition.ex: -------------------------------------------------------------------------------- 1 | defmodule Solid.BinaryCondition do 2 | alias Solid.{Argument, Filter} 3 | alias Solid.Literal.Empty 4 | 5 | defstruct [ 6 | :loc, 7 | :child_condition, 8 | :left_argument, 9 | :operator, 10 | :right_argument, 11 | left_argument_filters: [], 12 | right_argument_filters: [] 13 | ] 14 | 15 | @type t :: %__MODULE__{ 16 | loc: Solid.Parser.Loc.t(), 17 | child_condition: {:and | :or, t | Solid.UnaryCondition.t()} | nil, 18 | left_argument: Argument.t(), 19 | left_argument_filters: [Filter.t()], 20 | operator: Solid.Lexer.operator(), 21 | right_argument: Argument.t(), 22 | right_argument_filters: [Filter.t()] 23 | } 24 | 25 | @spec eval({term, Solid.Lexer.operator(), term}) :: {:ok, boolean} | {:error, binary} 26 | def eval({v1, :==, %Empty{}}) when is_map(v1) and not is_struct(v1), do: {:ok, v1 == %{}} 27 | def eval({%Empty{}, :==, v2}) when is_map(v2) and not is_struct(v2), do: {:ok, v2 == %{}} 28 | 29 | def eval({v1, :==, %Empty{}}) when is_list(v1), do: {:ok, v1 == []} 30 | def eval({%Empty{}, :==, v2}) when is_list(v2), do: {:ok, v2 == []} 31 | 32 | def eval({v1, _, %Empty{}}) when is_map(v1) and not is_struct(v1), do: {:ok, false} 33 | def eval({%Empty{}, _, v2}) when is_map(v2) and not is_struct(v2), do: {:ok, false} 34 | 35 | def eval({v1, _, v2}) 36 | when (is_map(v1) or is_map(v2)) and not is_struct(v1) and not is_struct(v2), 37 | do: {:ok, false} 38 | 39 | def eval({nil, :contains, _v2}), do: {:ok, false} 40 | def eval({_v1, :contains, nil}), do: {:ok, false} 41 | def eval({v1, :contains, v2}) when is_list(v1), do: {:ok, v2 in v1} 42 | 43 | def eval({v1, :contains, v2}) when is_binary(v1) and is_binary(v2), 44 | do: {:ok, String.contains?(v1, v2)} 45 | 46 | def eval({v1, :contains, v2}) when is_binary(v1), do: {:ok, String.contains?(v1, to_string(v2))} 47 | 48 | def eval({_v1, :contains, _v2}), do: {:ok, false} 49 | 50 | def eval({v1, :<=, nil}) when is_number(v1), do: {:ok, false} 51 | def eval({v1, :<, nil}) when is_number(v1), do: {:ok, false} 52 | def eval({nil, :>=, v2}) when is_number(v2), do: {:ok, false} 53 | def eval({nil, :>, v2}) when is_number(v2), do: {:ok, false} 54 | 55 | def eval({v1, op, v2}) 56 | when op in ~w(< <= > >=)a and is_binary(v1) and is_integer(v2) do 57 | {:error, "comparison of String with #{v2} failed"} 58 | end 59 | 60 | def eval({v1, op, v2}) 61 | when op in ~w(< <= > >=)a and is_integer(v1) and is_binary(v2) do 62 | {:error, "comparison of Integer with String failed"} 63 | end 64 | 65 | def eval({v1, op, v2}) 66 | when op in ~w(< <= > >=)a and is_float(v1) and is_binary(v2) do 67 | {:error, "comparison of Float with String failed"} 68 | end 69 | 70 | def eval({v1, :<>, v2}), do: {:ok, apply(Kernel, :!=, [v1, v2])} 71 | def eval({v1, op, v2}), do: {:ok, apply(Kernel, op, [v1, v2])} 72 | end 73 | -------------------------------------------------------------------------------- /test/solid/integration/scenarios/shop/input.liquid: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 | {% for image in product.images %} 5 | {% if forloop.first %} 6 | 7 | {{product.title | escape }} 8 | 9 | {% else %} 10 | 11 | {{product.title | escape }} 12 | 13 | {% endif %} 14 | {% endfor %} 15 |
16 | 17 |

{{ product.title }}

18 | 19 |
    20 |
  • Vendor: {{ product.vendor | link_to_vendor }}
  • 21 |
  • Type: {{ product.type | link_to_type }}
  • 22 |
23 | 24 | {{ product.price_min | money }}{% if product.price_varies %} - {{ product.price_max | money }}{% endif %} 25 | 26 |
27 |
28 | 29 | 34 | 35 |
36 | 37 |
38 |
39 |
40 | 41 |
42 | {{ product.description }} 43 |
44 |
45 | 46 | 68 | -------------------------------------------------------------------------------- /test/solid/integration/scenarios/tablerow/input.liquid: -------------------------------------------------------------------------------- 1 | {% tablerow i in (1..5) %} 2 | {{ i }} 3 | {% endtablerow %} 4 | 5 | {% tablerow product in products %} 6 | {{ product.title }} 7 | {% endtablerow %} 8 | 9 | # Both should be empty 10 | {{ product }} {{ tablerowloop.first }} 11 | 12 | {% tablerow product in products offset: 1 %} 13 | {{ product.title }} 14 | {% endtablerow %} 15 | 16 | {% tablerow product in products offset: '1' %} 17 | {{ product.title }} 18 | {% endtablerow %} 19 | 20 | {% tablerow product in products offset: unknown %} 21 | {{ product.title }} 22 | {% endtablerow %} 23 | 24 | {% tablerow product in products limit: 1 %} 25 | {{ product.title }} 26 | {% endtablerow %} 27 | 28 | {% tablerow product in products limit: '2' %} 29 | {{ product.title }} 30 | {% endtablerow %} 31 | 32 | {% tablerow product in products cols: 2 %} 33 | {{ product.title }} 34 | {% endtablerow %} 35 | 36 | {% tablerow product in products cols: unknown %} 37 | {{ product.title }} 38 | {% endtablerow %} 39 | 40 | {% tablerow product in products cols: '2' %} 41 | {{ product.title }} 42 | {% endtablerow %} 43 | 44 | {% tablerow product in products cols: 1 %} 45 | {{ product.title }} 46 | {% endtablerow %} 47 | 48 | {% tablerow product in products offset: 1, limit: 5, cols: 2 %} 49 | {{ product.title }} 50 | {% endtablerow %} 51 | 52 | tablerowloop 53 | {% tablerow product in products cols: 2 %} 54 | {{ tablerowloop.col }} 55 | {{ tablerowloop.col0 }} 56 | {{ tablerowloop.col_first }} 57 | {{ tablerowloop.col_last }} 58 | {{ tablerowloop.row }} 59 | {{ tablerowloop.first }} 60 | {{ tablerowloop.last }} 61 | {{ tablerowloop.length }} 62 | {{ tablerowloop.index }} 63 | {{ tablerowloop.index0 }} 64 | {{ tablerowloop.rindex }} 65 | {{ tablerowloop.rindex0 }} 66 | {% endtablerow %} 67 | 68 | {% tablerow product in products %} 69 | before break 70 | {% if tablerowloop.first %} 71 | {% break %} 72 | {% endif %} 73 | after break 74 | {% endtablerow %} 75 | 76 | {% tablerow product in products cols: 2 %} 77 | before break 78 | {% if tablerowloop.first %} 79 | {% break %} 80 | {% endif %} 81 | after break 82 | {% endtablerow %} 83 | 84 | {% tablerow product in products %} 85 | before break 86 | {% if tablerowloop.last %} 87 | {% break %} 88 | {% endif %} 89 | after break 90 | {% endtablerow %} 91 | 92 | {% tablerow product in products cols: 2 %} 93 | before break 94 | {% if tablerowloop.last %} 95 | {% break %} 96 | {% endif %} 97 | after break 98 | {% endtablerow %} 99 | 100 | {% tablerow product in products %} 101 | before continue 102 | {% if tablerowloop.first %} 103 | {% continue %} 104 | {% endif %} 105 | after continue 106 | {% endtablerow %} 107 | 108 | {% tablerow product in products cols: 2 %} 109 | before continue 110 | {% if tablerowloop.first %} 111 | {% continue %} 112 | {% endif %} 113 | after continue 114 | {% endtablerow %} 115 | -------------------------------------------------------------------------------- /test/solid/tags/inline_comment_tag_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Solid.Tags.InlineCommenTagTest do 2 | use ExUnit.Case, async: true 3 | alias Solid.{Lexer, ParserContext} 4 | alias Solid.Tags.InlineCommentTag 5 | alias Solid.Parser.Loc 6 | 7 | defp parse(template) do 8 | context = %ParserContext{rest: template, line: 1, column: 1, mode: :normal} 9 | 10 | with {:ok, "#", context} <- Lexer.tokenize_tag_start(context) do 11 | InlineCommentTag.parse("#", %Loc{line: 1, column: 1}, context) 12 | end 13 | end 14 | 15 | describe "parse/2" do 16 | test "basic" do 17 | template = ~s<{% # a comment $ %} {{ yo }}> 18 | 19 | assert parse(template) == { 20 | :ok, 21 | %InlineCommentTag{loc: %Loc{column: 1, line: 1}}, 22 | %ParserContext{column: 20, line: 1, mode: :normal, rest: " {{ yo }}"} 23 | } 24 | end 25 | 26 | test "empty" do 27 | template = ~s<{%#%} {{ yo }}> 28 | 29 | assert parse(template) == { 30 | :ok, 31 | %Solid.Tags.InlineCommentTag{ 32 | loc: %Loc{column: 1, line: 1} 33 | }, 34 | %Solid.ParserContext{ 35 | column: 6, 36 | line: 1, 37 | mode: :normal, 38 | rest: " {{ yo }}" 39 | } 40 | } 41 | end 42 | 43 | test "multiline" do 44 | template = """ 45 | {% # a comment 46 | 47 | # another comment 48 | 49 | %} 50 | {{ yo }} 51 | """ 52 | 53 | assert parse(template) == { 54 | :ok, 55 | %Solid.Tags.InlineCommentTag{ 56 | loc: %Loc{column: 1, line: 1} 57 | }, 58 | %Solid.ParserContext{ 59 | column: 5, 60 | line: 5, 61 | mode: :normal, 62 | rest: "\n{{ yo }}\n" 63 | } 64 | } 65 | end 66 | 67 | test "whitespace control" do 68 | template = """ 69 | {%- # a comment 70 | 71 | # another comment 72 | 73 | -%} 74 | {{ yo }} 75 | """ 76 | 77 | assert parse(template) == { 78 | :ok, 79 | %Solid.Tags.InlineCommentTag{ 80 | loc: %Loc{column: 1, line: 1} 81 | }, 82 | %Solid.ParserContext{ 83 | column: 1, 84 | line: 6, 85 | mode: :normal, 86 | rest: "{{ yo }}\n" 87 | } 88 | } 89 | end 90 | end 91 | 92 | describe "Renderable impl" do 93 | test "does nothing" do 94 | template = ~s<{% # a comment $ %} {{ yo }}> 95 | context = %Solid.Context{} 96 | 97 | {:ok, tag, _rest} = parse(template) 98 | 99 | assert Solid.Renderable.render(tag, context, []) == {[], %Solid.Context{}} 100 | end 101 | end 102 | end 103 | -------------------------------------------------------------------------------- /lib/solid/sigil.ex: -------------------------------------------------------------------------------- 1 | defmodule Solid.Sigil do 2 | @moduledoc """ 3 | Provides the `~LIQUID` sigil for validating and compiling Liquid templates using Solid. 4 | 5 | This sigil validates the template at compile time and returns a compiled Solid template. 6 | If the template has syntax errors, it will raise a CompileError with detailed information. 7 | 8 | ## Examples 9 | 10 | iex> import Solid.Sigil 11 | iex> template = ~LIQUID\"\"\" 12 | ...> Hello, {{ name }}! 13 | ...> \"\"\" 14 | iex> Solid.render(template, %{"name" => "World"}) 15 | {:ok, "Hello, World!"} 16 | 17 | An optional module attribute @liquid_tags can set which tags will be used while parsing. 18 | 19 | defmodule MyModule do 20 | import Solid.Sigil 21 | 22 | @liquid_tags Solid.Tag.default_tags() |> Map.put("current_line", CustomTags.CurrentLine) 23 | 24 | def template do 25 | ~LIQUID"{% current_line %}" 26 | end 27 | end 28 | """ 29 | 30 | # Custom sigil for validating and compiling Liquid templates using Solid 31 | defmacro sigil_LIQUID({:<<>>, _meta, [string]}, _modifiers) do 32 | line = __CALLER__.line 33 | file = __CALLER__.file 34 | 35 | tags = 36 | if __CALLER__.module do 37 | Module.get_attribute(__CALLER__.module, :liquid_tags) 38 | end 39 | 40 | opts = if tags, do: [tags: tags], else: [] 41 | 42 | try do 43 | # Validate the template during compile time 44 | parsed_template = Solid.parse!(string, opts) 45 | 46 | # Return the parsed template 47 | Macro.escape(parsed_template) 48 | rescue 49 | e in Solid.TemplateError -> 50 | # Grab just the first error 51 | error = hd(e.errors) 52 | # Extract template line number (first element of the tuple) 53 | template_line = error.meta.line 54 | # Calculate actual line number in the file 55 | actual_line = line + template_line 56 | 57 | # Extract just the problematic portion of the template 58 | template_lines = String.split(string, "\n") 59 | context_start = max(0, template_line - 2) 60 | context_end = min(length(template_lines), template_line + 2) 61 | 62 | context_lines = 63 | template_lines 64 | |> Enum.slice(context_start, context_end - context_start) 65 | |> Enum.with_index(line + context_start + 1) 66 | |> Enum.map_join("\n", fn {line_text, idx} -> 67 | indicator = if idx == actual_line, do: "→ ", else: " " 68 | "#{indicator}#{idx}: #{line_text}" 69 | end) 70 | 71 | # Prepare a more helpful error message 72 | message = """ 73 | Liquid template syntax error at line #{actual_line}: 74 | 75 | #{context_lines} 76 | 77 | Error: #{error.reason} 78 | """ 79 | 80 | # Re-raise with better context 81 | reraise %CompileError{ 82 | file: file, 83 | line: actual_line, 84 | description: message 85 | }, 86 | __STACKTRACE__ 87 | end 88 | end 89 | end 90 | -------------------------------------------------------------------------------- /test/solid/tags/capture_tag_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Solid.Tags.CaptureTagTest do 2 | use ExUnit.Case, async: true 3 | alias Solid.Tags.CaptureTag 4 | alias Solid.{Lexer, ParserContext} 5 | alias Solid.Parser.Loc 6 | 7 | defp parse(template) do 8 | context = %ParserContext{rest: template, line: 1, column: 1, mode: :normal} 9 | 10 | with {:ok, "capture", context} <- Lexer.tokenize_tag_start(context) do 11 | CaptureTag.parse("capture", %Loc{line: 1, column: 1}, context) 12 | end 13 | end 14 | 15 | describe "parse/2" do 16 | test "basic" do 17 | template = ~s<{% capture var1 %} {{ yo }} {% endcapture %}> 18 | 19 | assert parse(template) == 20 | {:ok, 21 | %CaptureTag{ 22 | loc: %Loc{line: 1, column: 1}, 23 | argument: %Solid.Variable{ 24 | original_name: "var1", 25 | loc: %Loc{column: 12, line: 1}, 26 | identifier: "var1", 27 | accesses: [] 28 | }, 29 | body: [ 30 | %Solid.Text{loc: %Loc{column: 19, line: 1}, text: " "}, 31 | %Solid.Object{ 32 | loc: %Loc{column: 23, line: 1}, 33 | argument: %Solid.Variable{ 34 | original_name: "yo", 35 | loc: %Loc{column: 23, line: 1}, 36 | identifier: "yo", 37 | accesses: [] 38 | }, 39 | filters: [] 40 | }, 41 | %Solid.Text{loc: %Loc{column: 28, line: 1}, text: " "} 42 | ] 43 | }, %ParserContext{rest: "", line: 1, column: 45, mode: :normal}} 44 | end 45 | 46 | test "error" do 47 | template = ~s<{% capture | %}> 48 | assert parse(template) == {:error, "Argument expected", %{column: 12, line: 1}} 49 | end 50 | 51 | test "error extra token" do 52 | template = ~s<{% capture var1 = true %}> 53 | assert parse(template) == {:error, "Unexpected token", %{column: 17, line: 1}} 54 | end 55 | end 56 | 57 | describe "Renderable impl" do 58 | test "capture captures" do 59 | template = ~s<{% capture var1 -%} {{ yo }} captured {%- endcapture %}> 60 | context = %Solid.Context{vars: %{"yo" => "HEY!"}} 61 | 62 | {:ok, tag, _rest} = parse(template) 63 | 64 | assert Solid.Renderable.render(tag, context, []) == 65 | {[], %Solid.Context{vars: %{"yo" => "HEY!", "var1" => "HEY! captured"}}} 66 | end 67 | 68 | test "capture with accesses" do 69 | template = ~s<{% capture var1[1]['abc'] -%} {{ yo }} captured {%- endcapture %}> 70 | context = %Solid.Context{vars: %{"yo" => "HEY!"}} 71 | 72 | {:ok, tag, _rest} = parse(template) 73 | 74 | assert Solid.Renderable.render(tag, context, []) == 75 | {[], 76 | %Solid.Context{ 77 | vars: %{ 78 | "yo" => "HEY!", 79 | "var1[1]['abc']" => "HEY! captured" 80 | } 81 | }} 82 | end 83 | end 84 | end 85 | -------------------------------------------------------------------------------- /test/solid/integration/scenarios/products/input.liquid: -------------------------------------------------------------------------------- 1 |
2 |
3 |

Three Great Reasons You Should Shop With Us...

4 |
    5 |
  • 6 |

    Free Shipping

    7 |

    On all orders over $25

    8 |
  • 9 |
  • 10 |

    Top Quality

    11 |

    Hand made in our shop

    12 |
  • 13 |
  • 14 |

    100% Guarantee

    15 |

    Any time, any reason

    16 |
  • 17 |
18 |
19 |
20 | 21 |
22 | 23 |
{{pages.alert.content}}
24 | 25 |
    26 | 27 | {% for product in collections.frontpage.products %} 28 |
  • 29 |
    30 |
    31 |
    32 |
    33 |

    {{product.title}}

    34 | {{ product.description | truncatewords: 15 }}

    35 |
    36 | {{ product.title | escape }} 37 |
    38 | 39 |
    40 | 41 | 42 |

    43 | View Details 44 | 45 | 46 | {% if product.compare_at_price %} 47 | {% if product.price_min != product.compare_at_price %} 48 | {{product.compare_at_price | money}} - 49 | {% endif %} 50 | {% endif %} 51 | 52 | {{product.price_min | money}} 53 | 54 | 55 |

    56 |
    57 |
    58 |
    59 |
  • 60 | {% endfor %} 61 | 62 |
63 | 64 |
65 |
66 |

Why Shop With Us?

67 |
    68 |
  • 69 |

    24 Hours

    70 |

    We're always here to help.

    71 |
  • 72 |
  • 73 |

    No Spam

    74 |

    We'll never share your info.

    75 |
  • 76 |
  • 77 |

    Save Energy

    78 |

    We're green, all the way.

    79 |
  • 80 |
  • 81 |

    Secure Servers

    82 |

    Checkout is 256bits encrypted.

    83 |
  • 84 |
85 |
86 | 87 |
88 |

Our Company

89 | {{pages.about-us.content | truncatewords: 49}} read more

90 |
91 |
92 | 93 |
94 | 95 | -------------------------------------------------------------------------------- /lib/solid/html.ex: -------------------------------------------------------------------------------- 1 | defmodule Solid.HTML do 2 | # Vendored in from Plug.HTML 3 | # 4 | # 5 | # Copyright (c) 2013 Plataformatec. 6 | 7 | # Licensed under the Apache License, Version 2.0 (the "License"); 8 | # you may not use this file except in compliance with the License. 9 | # You may obtain a copy of the License at 10 | 11 | # http://www.apache.org/licenses/LICENSE-2.0 12 | 13 | # Unless required by applicable law or agreed to in writing, software 14 | # distributed under the License is distributed on an "AS IS" BASIS, 15 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 16 | # See the License for the specific language governing permissions and 17 | # limitations under the License. 18 | # 19 | 20 | @moduledoc """ 21 | Conveniences for generating HTML. 22 | """ 23 | 24 | @doc ~S""" 25 | Escapes the given HTML to string. 26 | 27 | iex> Plug.HTML.html_escape("foo") 28 | "foo" 29 | 30 | iex> Plug.HTML.html_escape("") 31 | "<foo>" 32 | 33 | iex> Plug.HTML.html_escape("quotes: \" & \'") 34 | "quotes: " & '" 35 | """ 36 | @spec html_escape(String.t()) :: String.t() 37 | def html_escape(data) when is_binary(data) do 38 | IO.iodata_to_binary(to_iodata(data, 0, data, [])) 39 | end 40 | 41 | @doc ~S""" 42 | Escapes the given HTML to iodata. 43 | 44 | iex> Plug.HTML.html_escape_to_iodata("foo") 45 | "foo" 46 | 47 | iex> Plug.HTML.html_escape_to_iodata("") 48 | [[[] | "<"], "foo" | ">"] 49 | 50 | iex> Plug.HTML.html_escape_to_iodata("quotes: \" & \'") 51 | [[[[], "quotes: " | """], " " | "&"], " " | "'"] 52 | 53 | """ 54 | @spec html_escape_to_iodata(String.t()) :: iodata 55 | def html_escape_to_iodata(data) when is_binary(data) do 56 | to_iodata(data, 0, data, []) 57 | end 58 | 59 | escapes = [ 60 | {?<, "<"}, 61 | {?>, ">"}, 62 | {?&, "&"}, 63 | {?", """}, 64 | {?', "'"} 65 | ] 66 | 67 | for {match, insert} <- escapes do 68 | defp to_iodata(<>, skip, original, acc) do 69 | to_iodata(rest, skip + 1, original, [acc | unquote(insert)]) 70 | end 71 | end 72 | 73 | defp to_iodata(<<_char, rest::bits>>, skip, original, acc) do 74 | to_iodata(rest, skip, original, acc, 1) 75 | end 76 | 77 | defp to_iodata(<<>>, _skip, _original, acc) do 78 | acc 79 | end 80 | 81 | for {match, insert} <- escapes do 82 | defp to_iodata(<>, skip, original, acc, len) do 83 | part = binary_part(original, skip, len) 84 | to_iodata(rest, skip + len + 1, original, [acc, part | unquote(insert)]) 85 | end 86 | end 87 | 88 | defp to_iodata(<<_char, rest::bits>>, skip, original, acc, len) do 89 | to_iodata(rest, skip, original, acc, len + 1) 90 | end 91 | 92 | defp to_iodata(<<>>, 0, original, _acc, _len) do 93 | original 94 | end 95 | 96 | defp to_iodata(<<>>, skip, original, acc, len) do 97 | [acc | binary_part(original, skip, len)] 98 | end 99 | 100 | # Addition 101 | for {match, insert} <- escapes do 102 | def replacements(<>), do: unquote(insert) 103 | end 104 | end 105 | -------------------------------------------------------------------------------- /test/solid/object_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Solid.ObjectTest do 2 | use ExUnit.Case, async: true 3 | alias Solid.{Lexer, Object, ParserContext} 4 | alias Solid.Parser.Loc 5 | alias Solid.Renderable 6 | 7 | defp parse(template) do 8 | context = %ParserContext{rest: "{{#{template}}}", line: 1, column: 1, mode: :normal} 9 | {:ok, tokens, _context} = Lexer.tokenize_object(context) 10 | Object.parse(tokens) 11 | end 12 | 13 | describe "parse/2" do 14 | test "empty tokens" do 15 | assert {:ok, object, [end: %{line: 1, column: 3}]} = parse("") 16 | 17 | assert object == %Object{ 18 | loc: %Loc{column: 3, line: 1}, 19 | argument: %Solid.Literal{loc: %Loc{column: 3, line: 1}, value: nil}, 20 | filters: [] 21 | } 22 | end 23 | 24 | test "object literal string" do 25 | assert parse("'a string'") == { 26 | :ok, 27 | %Object{ 28 | loc: %Loc{column: 3, line: 1}, 29 | argument: %Solid.Literal{ 30 | loc: %Loc{column: 3, line: 1}, 31 | value: "a string" 32 | }, 33 | filters: [] 34 | }, 35 | [end: %{line: 1, column: 13}] 36 | } 37 | end 38 | 39 | test "bracket variable" do 40 | template = "['a var'].foo }}" 41 | 42 | assert { 43 | :ok, 44 | %Solid.Object{ 45 | argument: %Solid.Variable{ 46 | accesses: [ 47 | %Solid.AccessLiteral{value: "a var"}, 48 | %Solid.AccessLiteral{value: "foo"} 49 | ], 50 | identifier: nil, 51 | original_name: "['a var'].foo" 52 | }, 53 | filters: [] 54 | }, 55 | [end: %{column: 17, line: 1}] 56 | } = parse(template) 57 | end 58 | 59 | test "broken bracket access" do 60 | template = "var[] }}" 61 | 62 | assert {:error, "Argument access expected", _meta} = parse(template) 63 | end 64 | 65 | test "broken dot access" do 66 | template = "var. }}" 67 | 68 | assert {:error, "Unexpected token", _meta} = parse(template) 69 | end 70 | 71 | test "incomplete filter arguments" do 72 | template = "'a string' | default: }}" 73 | 74 | assert {:error, "Arguments expected", _meta} = parse(template) 75 | end 76 | 77 | test "missing filter" do 78 | template = "'a string' | }}" 79 | 80 | assert {:error, "Filter expected", _meta} = parse(template) 81 | end 82 | end 83 | 84 | describe "Renderable impl" do 85 | test "basic var rendering" do 86 | template = "var1 }}" 87 | assert {:ok, object, _tokens} = parse(template) 88 | 89 | context = %Solid.Context{vars: %{"var1" => "the value"}} 90 | 91 | assert {"the value", ^context} = Renderable.render(object, context, []) 92 | end 93 | 94 | test "basic literal rendering" do 95 | template = "'a string' }}" 96 | assert {:ok, object, _tokens} = parse(template) 97 | 98 | context = %Solid.Context{} 99 | 100 | assert {"a string", ^context} = Renderable.render(object, context, []) 101 | end 102 | end 103 | end 104 | -------------------------------------------------------------------------------- /lib/solid/epoch_date_time_parser.ex: -------------------------------------------------------------------------------- 1 | # Fork of DateTimeParser.Parser.Epoch to NOT support negative unix epoch 2 | # https://github.com/dbernheisel/date_time_parser/blob/ead04e6075983411707c9fc414c87725e1904dc6/lib/parser/epoch.ex 3 | # The MIT License (MIT) 4 | # Copyright (c) 2018 David Bernheisel 5 | defmodule Solid.EpochDateTimeParser do 6 | @moduledoc """ 7 | Parses a Unix Epoch timestamp. This is gated by the number of present digits. It must contain 10 8 | or 11 seconds, with an optional subsecond up to 10 digits. Negative epoch timestamps are not 9 | supported. 10 | """ 11 | @behaviour DateTimeParser.Parser 12 | 13 | @max_subsecond_digits 6 14 | @epoch_regex ~r|\A(?\d{10,11})(?:\.(?\d{1,10}))?\z| 15 | 16 | @impl DateTimeParser.Parser 17 | def preflight(%{string: string} = parser) do 18 | case Regex.named_captures(@epoch_regex, string) do 19 | nil -> {:error, :not_compatible} 20 | results -> {:ok, %{parser | preflight: results}} 21 | end 22 | end 23 | 24 | @impl DateTimeParser.Parser 25 | def parse(%{preflight: preflight} = parser) do 26 | %{"seconds" => raw_seconds, "subseconds" => raw_subseconds} = preflight 27 | has_subseconds = raw_subseconds != "" 28 | 29 | with {:ok, seconds} <- parse_seconds(raw_seconds, has_subseconds), 30 | {:ok, subseconds} <- parse_subseconds(raw_subseconds) do 31 | from_tokens(parser, {seconds, subseconds}) 32 | end 33 | end 34 | 35 | @spec parse_seconds(String.t(), boolean()) :: {:ok, integer()} 36 | defp parse_seconds(raw_seconds, has_subseconds) 37 | 38 | defp parse_seconds(raw_seconds, _) do 39 | with {seconds, ""} <- Integer.parse(raw_seconds) do 40 | {:ok, seconds} 41 | end 42 | end 43 | 44 | @spec parse_subseconds(String.t()) :: {:ok, {integer(), integer()}} 45 | defp parse_subseconds(""), do: {:ok, {0, 0}} 46 | 47 | defp parse_subseconds(raw_subseconds) do 48 | with {subseconds, ""} <- Float.parse("0.#{raw_subseconds}") do 49 | microseconds = (subseconds * :math.pow(10, 6)) |> trunc() 50 | precision = min(String.length(raw_subseconds), @max_subsecond_digits) 51 | 52 | truncated_microseconds = 53 | microseconds 54 | |> Integer.digits() 55 | |> Enum.take(@max_subsecond_digits) 56 | |> Integer.undigits() 57 | 58 | {:ok, {truncated_microseconds, precision}} 59 | end 60 | end 61 | 62 | defp from_tokens(%{context: context}, {seconds, {microseconds, precision}}) do 63 | truncated_microseconds = 64 | microseconds 65 | |> Integer.digits() 66 | |> Enum.take(@max_subsecond_digits) 67 | |> Integer.undigits() 68 | 69 | with {:ok, datetime} <- DateTime.from_unix(seconds) do 70 | for_context(context, %{datetime | microsecond: {truncated_microseconds, precision}}) 71 | end 72 | end 73 | 74 | defp for_context(:best, result) do 75 | DateTimeParser.Parser.first_ok( 76 | [ 77 | fn -> for_context(:datetime, result) end, 78 | fn -> for_context(:date, result) end, 79 | fn -> for_context(:time, result) end 80 | ], 81 | "cannot convert #{inspect(result)} to context :best" 82 | ) 83 | end 84 | 85 | defp for_context(:datetime, datetime), do: {:ok, datetime} 86 | defp for_context(:date, datetime), do: {:ok, DateTime.to_date(datetime)} 87 | defp for_context(:time, datetime), do: {:ok, DateTime.to_time(datetime)} 88 | 89 | defp for_context(context, result) do 90 | {:error, "cannot convert #{inspect(result)} to context #{context}"} 91 | end 92 | end 93 | -------------------------------------------------------------------------------- /test/solid/tags/cycle_tag_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Solid.Tags.CycleTagTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias Solid.Tags.CycleTag 5 | alias Solid.{Lexer, ParserContext} 6 | alias Solid.Parser.Loc 7 | 8 | defp parse(template) do 9 | context = %ParserContext{rest: template, line: 1, column: 1, mode: :normal} 10 | 11 | with {:ok, "cycle", context} <- Lexer.tokenize_tag_start(context) do 12 | CycleTag.parse("cycle", %Loc{line: 1, column: 1}, context) 13 | end 14 | end 15 | 16 | describe "parse/2" do 17 | test "basic" do 18 | template = ~s<{% cycle var1, "b", 1 %}> 19 | 20 | assert parse(template) == { 21 | :ok, 22 | %CycleTag{ 23 | name: nil, 24 | loc: %Loc{line: 1, column: 1}, 25 | values: [ 26 | %Solid.Variable{ 27 | original_name: "var1", 28 | loc: %Loc{column: 10, line: 1}, 29 | identifier: "var1", 30 | accesses: [] 31 | }, 32 | %Solid.Literal{loc: %Loc{column: 16, line: 1}, value: "b"}, 33 | %Solid.Literal{loc: %Loc{column: 21, line: 1}, value: 1} 34 | ] 35 | }, 36 | %ParserContext{rest: "", line: 1, column: 25, mode: :normal} 37 | } 38 | end 39 | 40 | test "named" do 41 | template = ~s<{% cycle "c1": var1, "b", 1 %}> 42 | 43 | assert parse(template) == { 44 | :ok, 45 | %CycleTag{ 46 | loc: %Loc{column: 1, line: 1}, 47 | name: %Solid.Literal{loc: %Loc{column: 10, line: 1}, value: "c1"}, 48 | values: [ 49 | %Solid.Variable{ 50 | original_name: "var1", 51 | loc: %Loc{column: 16, line: 1}, 52 | identifier: "var1", 53 | accesses: [] 54 | }, 55 | %Solid.Literal{loc: %Loc{column: 22, line: 1}, value: "b"}, 56 | %Solid.Literal{loc: %Loc{column: 27, line: 1}, value: 1} 57 | ] 58 | }, 59 | %ParserContext{ 60 | column: 31, 61 | line: 1, 62 | mode: :normal, 63 | rest: "" 64 | } 65 | } 66 | end 67 | 68 | test "error" do 69 | template = ~s<{% cycle - %}> 70 | 71 | assert parse(template) == {:error, "Unexpected character '-'", %{line: 1, column: 10}} 72 | end 73 | end 74 | 75 | describe "Renderable impl" do 76 | test "cycle prints" do 77 | template = ~s<{% cycle "one", "two", "three" %}> 78 | context = %Solid.Context{} 79 | 80 | {:ok, tag, _rest} = parse(template) 81 | 82 | assert Solid.Renderable.render(tag, context, []) == 83 | { 84 | ["one"], 85 | %Solid.Context{ 86 | cycle_state: %{ 87 | "l:one,l:two,l:three" => 88 | {0, 89 | %{ 90 | 0 => %Solid.Literal{loc: %Loc{column: 10, line: 1}, value: "one"}, 91 | 1 => %Solid.Literal{loc: %Loc{column: 17, line: 1}, value: "two"}, 92 | 2 => %Solid.Literal{loc: %Loc{column: 24, line: 1}, value: "three"} 93 | }} 94 | } 95 | } 96 | } 97 | end 98 | end 99 | end 100 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # 1.2.0 (2025-12-12) 2 | 3 | ## Enhancements 4 | 5 | * Accept function as custom_filters 6 | 7 | # 1.1.1 (2025-09-20) 8 | 9 | ## Enhancements 10 | 11 | * Align the `Solid.UndefinedFilterError` message with `Solid.UndefinedVariableError` - include line number 12 | 13 | ## Bug fixes 14 | 15 | * Return `{:error, errors}` tuple when both strict_filters and strict_variables are enforced while rendering a template 16 | * Use correct variable name in the `Solid.UndefinedVariableError` message 17 | * Fix `strip_html` filter to handle multiline comments 18 | * Fix nil argument for `replace_last` filter 19 | * Fix `replace_last` filter bug with duplicate substrings 20 | * Fix non-list inputs in `sort_natural` filter 21 | * Fix `replace_first` filter for nil argument 22 | 23 | # 1.0.1 (2025-07-04) 24 | 25 | ## Bug fixes 26 | 27 | * Fix parsing error when tags were incomplete 28 | * Point to the opening tag/object line and column when they are not closed properly 29 | 30 | # 1.0.0 (2025-06-16) 31 | 32 | ## Enhancements 33 | 34 | * Error messages are now more detailed; 35 | * Parsing can now fail with a list of errors instead of stopping on the first error; 36 | * `liquid` and the inline comment tag are now supported; 37 | 38 | ## Bug fixes 39 | 40 | ## Breaking changes 41 | 42 | * Parsing engine has been rewritten from scratch. Any custom tags will need to reimplemented using the `Solid.Parser` & `Solid.Lexer` functions. See existing tags as example; 43 | * `Solid.parse/2` returns more meaningful errors and it tries to parse the whole file even when some errors are found. Example: 44 | 45 | ```elixir 46 | """ 47 | {{ - }} 48 | 49 | {% unknown %} 50 | 51 | {% if true %} 52 | {% endunless % } 53 | {% echo 'yo' %} 54 | """ 55 | |> Solid.parse!() 56 | 57 | ** (Solid.TemplateError) Unexpected character '-' 58 | 1: {{ - }} 59 | ^ 60 | Unexpected tag 'unknown' 61 | 3: {% unknown %} 62 | ^ 63 | Expected one of 'elsif', 'else', 'endif' tags. Got: Unexpected tag 'endunless' 64 | 6: {% endunless % } 65 | ^ 66 | Unexpected tag 'endunless' 67 | 6: {% endunless % } 68 | ^ 69 | (solid 1.0.0-rc.0) lib/solid.ex:77: Solid.parse!/2 70 | iex:2: (file) 71 | ``` 72 | 73 | * `Solid.render/3` now always return `{:ok, result, errors}` unless `strict_variables` or `strict_filters` are enabled and a filter or a variable was not found during rendering. See examples below: 74 | 75 | ```elixir 76 | """ 77 | {{ 1 | base64_url_safe_decode }} 78 | """ 79 | |> Solid.parse!() 80 | |> Solid.render(%{}) 81 | 82 | {:ok, 83 | ["Liquid error (line 1): invalid base64 provided to base64_url_safe_decode", 84 | "\n"], 85 | [ 86 | %Solid.ArgumentError{ 87 | message: "invalid base64 provided to base64_url_safe_decode", 88 | loc: %Solid.Parser.Loc{line: 1, column: 8} 89 | } 90 | ]} 91 | ``` 92 | 93 | ```elixir 94 | "{{ missing_var }} 123" 95 | |> Solid.parse!() 96 | |> Solid.render(%{}) 97 | 98 | {:ok, ["", " 123"], []} 99 | ``` 100 | 101 | ```elixir 102 | "{{ missing_var }}" 103 | |> Solid.parse!() 104 | |> Solid.render(%{}, strict_variables: true) 105 | 106 | {:error, 107 | [ 108 | %Solid.UndefinedVariableError{ 109 | variable: ["missing_var"], 110 | loc: %Solid.Parser.Loc{line: 1, column: 4} 111 | } 112 | ], [""]} 113 | ``` 114 | 115 | ```elixir 116 | "{{ 1 | my_sum }}" 117 | |> Solid.parse!() 118 | |> Solid.render(%{}) 119 | 120 | {:ok, ["1"], []} 121 | ``` 122 | 123 | ```elixir 124 | "{{ 1 | my_sum }}" 125 | |> Solid.parse!() 126 | |> Solid.render(%{}, strict_filters: true) 127 | 128 | {:error, 129 | [ 130 | %Solid.UndefinedFilterError{ 131 | filter: "my_sum", 132 | loc: %Solid.Parser.Loc{line: 1, column: 8} 133 | } 134 | ], ["1"]} 135 | ``` 136 | 137 | * `Solid.FileSystem.read_template_file/2` now must return a tuple with the file content or an error tuple. 138 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "date_time_parser": {:hex, :date_time_parser, "1.2.0", "3d5a816b91967f51e0f94dcb16a34b2cb780f22cd48931779e81d72f7d3eadb1", [:mix], [{:kday, "~> 1.0", [hex: :kday, repo: "hexpm", optional: false]}], "hexpm", "0cf09ada9f42c0b3bfba02dc0ea2e4b4d2f543d9d2bf99b831a29e6b4a4160e5"}, 3 | "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"}, 4 | "dialyxir": {:hex, :dialyxir, "1.4.3", "edd0124f358f0b9e95bfe53a9fcf806d615d8f838e2202a9f430d59566b6b53b", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "bf2cfb75cd5c5006bec30141b131663299c661a864ec7fbbc72dfa557487a986"}, 5 | "earmark_parser": {:hex, :earmark_parser, "1.4.43", "34b2f401fe473080e39ff2b90feb8ddfeef7639f8ee0bbf71bb41911831d77c5", [:mix], [], "hexpm", "970a3cd19503f5e8e527a190662be2cee5d98eed1ff72ed9b3d1a3d466692de8"}, 6 | "erlex": {:hex, :erlex, "0.2.7", "810e8725f96ab74d17aac676e748627a07bc87eb950d2b83acd29dc047a30595", [:mix], [], "hexpm", "3ed95f79d1a844c3f6bf0cea61e0d5612a42ce56da9c03f01df538685365efb0"}, 7 | "ex_doc": {:hex, :ex_doc, "0.37.2", "2a3aa7014094f0e4e286a82aa5194a34dd17057160988b8509b15aa6c292720c", [:mix], [{:earmark_parser, "~> 1.4.42", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "4dfa56075ce4887e4e8b1dcc121cd5fcb0f02b00391fd367ff5336d98fa49049"}, 8 | "file_system": {:hex, :file_system, "1.1.0", "08d232062284546c6c34426997dd7ef6ec9f8bbd090eb91780283c9016840e8f", [:mix], [], "hexpm", "bfcf81244f416871f2a2e15c1b515287faa5db9c6bcf290222206d120b3d43f6"}, 9 | "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, 10 | "kday": {:hex, :kday, "1.1.0", "64efac85279a12283eaaf3ad6f13001ca2dff943eda8c53288179775a8c057a0", [:mix], [{:ex_doc, "~> 0.21", [hex: :ex_doc, repo: "hexpm", optional: true]}], "hexpm", "69703055d63b8d5b260479266c78b0b3e66f7aecdd2022906cd9bf09892a266d"}, 11 | "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, 12 | "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, 13 | "makeup_erlang": {:hex, :makeup_erlang, "1.0.2", "03e1804074b3aa64d5fad7aa64601ed0fb395337b982d9bcf04029d68d51b6a7", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "af33ff7ef368d5893e4a267933e7744e46ce3cf1f61e2dccf53a111ed3aa3727"}, 14 | "mix_test_watch": {:hex, :mix_test_watch, "1.2.0", "1f9acd9e1104f62f280e30fc2243ae5e6d8ddc2f7f4dc9bceb454b9a41c82b42", [:mix], [{:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}], "hexpm", "278dc955c20b3fb9a3168b5c2493c2e5cffad133548d307e0a50c7f2cfbf34f6"}, 15 | "nimble_parsec": {:hex, :nimble_parsec, "1.4.0", "51f9b613ea62cfa97b25ccc2c1b4216e81df970acd8e16e8d1bdc58fef21370d", [:mix], [], "hexpm", "9c565862810fb383e9838c1dd2d7d2c437b3d13b267414ba6af33e50d2d1cf28"}, 16 | } 17 | -------------------------------------------------------------------------------- /test/solid/tags/counter_tag_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Solid.Tags.CounterTagTest do 2 | use ExUnit.Case, async: true 3 | alias Solid.Tags.CounterTag 4 | alias Solid.{Context, Lexer, ParserContext, Renderable} 5 | alias Solid.Parser.Loc 6 | 7 | defp parse(template) do 8 | context = %ParserContext{rest: template, line: 1, column: 1, mode: :normal} 9 | 10 | with {:ok, tag_name, context} <- Lexer.tokenize_tag_start(context) do 11 | CounterTag.parse(tag_name, %Loc{line: 1, column: 1}, context) 12 | end 13 | end 14 | 15 | describe "parse/1" do 16 | test "increment" do 17 | template = ~s<{% increment var1 %}> 18 | 19 | assert parse(template) == 20 | { 21 | :ok, 22 | %CounterTag{ 23 | argument: %Solid.Variable{ 24 | original_name: "var1", 25 | loc: %Loc{column: 14, line: 1}, 26 | identifier: "var1", 27 | accesses: [] 28 | }, 29 | loc: %Loc{column: 1, line: 1}, 30 | operation: :increment 31 | }, 32 | %ParserContext{rest: "", line: 1, column: 21, mode: :normal} 33 | } 34 | end 35 | 36 | test "decrement" do 37 | template = ~s<{% decrement var1 %}> 38 | 39 | assert parse(template) == 40 | { 41 | :ok, 42 | %CounterTag{ 43 | argument: %Solid.Variable{ 44 | original_name: "var1", 45 | loc: %Loc{column: 14, line: 1}, 46 | identifier: "var1", 47 | accesses: [] 48 | }, 49 | loc: %Loc{column: 1, line: 1}, 50 | operation: :decrement 51 | }, 52 | %ParserContext{rest: "", line: 1, column: 21, mode: :normal} 53 | } 54 | end 55 | 56 | test "error missing variable" do 57 | template = ~s<{% increment %}> 58 | assert parse(template) == {:error, "Argument expected", %{line: 1, column: 14}} 59 | end 60 | 61 | test "error extra tokens" do 62 | template = ~s<{% increment var1 | default: 3 %}> 63 | 64 | assert parse(template) == 65 | {:error, "Unexpected token after argument", %{line: 1, column: 19}} 66 | end 67 | end 68 | 69 | describe "Renderable impl" do 70 | test "increment no previous value" do 71 | template = ~s<{% increment var1 %}> 72 | context = %Context{} 73 | 74 | {:ok, tag, _rest} = parse(template) 75 | 76 | assert Renderable.render(tag, context, []) == 77 | {["0"], %Context{counter_vars: %{"var1" => 1}}} 78 | end 79 | 80 | test "increment with previous value" do 81 | template = ~s<{% increment var1 %}> 82 | context = %Context{counter_vars: %{"var1" => 41}} 83 | 84 | {:ok, tag, _rest} = parse(template) 85 | 86 | assert Renderable.render(tag, context, []) == 87 | {["41"], %Context{counter_vars: %{"var1" => 42}}} 88 | end 89 | 90 | test "decrement no previous value" do 91 | template = ~s<{% decrement var1 %}> 92 | context = %Context{} 93 | 94 | {:ok, tag, _rest} = parse(template) 95 | 96 | assert Renderable.render(tag, context, []) == 97 | {["-1"], %Context{counter_vars: %{"var1" => -1}}} 98 | end 99 | 100 | test "decrement with previous value" do 101 | template = ~s<{% decrement var1 %}> 102 | context = %Context{counter_vars: %{"var1" => 41}} 103 | 104 | {:ok, tag, _rest} = parse(template) 105 | 106 | assert Renderable.render(tag, context, []) == 107 | {["40"], %Context{counter_vars: %{"var1" => 40}}} 108 | end 109 | end 110 | end 111 | -------------------------------------------------------------------------------- /lib/solid/tags/raw_tag.ex: -------------------------------------------------------------------------------- 1 | defmodule Solid.Tags.RawTag do 2 | alias Solid.ParserContext 3 | @enforce_keys [:loc, :text] 4 | defstruct [:loc, :text] 5 | 6 | @behaviour Solid.Tag 7 | 8 | @impl true 9 | def parse("raw", loc, context) do 10 | with {:ok, tokens, context} <- Solid.Lexer.tokenize_tag_end(context), 11 | {:tokens, [{:end, _}]} <- {:tokens, tokens}, 12 | {:ok, raw_body, context} <- parse_raw_body(context, [], []) do 13 | {:ok, %__MODULE__{loc: loc, text: IO.iodata_to_binary(raw_body)}, context} 14 | else 15 | {:tokens, tokens} -> {:error, "Unexpected token", Solid.Parser.meta_head(tokens)} 16 | {:error, reason, _rest, loc} -> {:error, reason, loc} 17 | error -> error 18 | end 19 | end 20 | 21 | @whitespaces [" ", "\f", "\r", "\t", "\v"] 22 | 23 | defp parse_raw_body(%ParserContext{mode: :normal} = context, buffer, trailing_ws) do 24 | case context.rest do 25 | <<"\n", rest::binary>> -> 26 | trailing_ws = ["\n" | trailing_ws] 27 | 28 | parse_raw_body( 29 | %{context | rest: rest, line: context.line + 1, column: 1}, 30 | buffer, 31 | trailing_ws 32 | ) 33 | 34 | <> when c in @whitespaces -> 35 | trailing_ws = [c | trailing_ws] 36 | parse_raw_body(%{context | rest: rest, column: context.column + 1}, buffer, trailing_ws) 37 | 38 | <<"{%", rest::binary>> -> 39 | case Solid.Parser.maybe_tokenize_tag("endraw", context) do 40 | {:tag, _tag_name, _tokens, context} -> 41 | # check for whitespace control: {%- 42 | if String.starts_with?(rest, "-") do 43 | {:ok, Enum.reverse(buffer), context} 44 | else 45 | {:ok, Enum.reverse(trailing_ws ++ buffer), context} 46 | end 47 | 48 | {:not_found, context} -> 49 | parse_raw_body( 50 | %{context | rest: rest, column: context.column + 2}, 51 | ["{%" | trailing_ws ++ buffer], 52 | [] 53 | ) 54 | end 55 | 56 | "" -> 57 | {:error, "Raw tag not terminated", %{line: context.line, column: context.column}} 58 | 59 | <> -> 60 | buffer = [c | trailing_ws ++ buffer] 61 | parse_raw_body(%{context | rest: rest, column: context.column + 1}, buffer, []) 62 | end 63 | end 64 | 65 | defp parse_raw_body(context, buffer, trailing_ws) do 66 | case context.rest do 67 | <<"\n", rest::binary>> -> 68 | case Solid.Parser.maybe_tokenize_tag("endraw", context) do 69 | {:tag, _tag_name, _tokens, context} -> 70 | {:ok, Enum.reverse(trailing_ws ++ buffer), context} 71 | 72 | {:not_found, context} -> 73 | trailing_ws = ["\n" | trailing_ws] 74 | 75 | parse_raw_body( 76 | %{context | rest: rest, line: context.line + 1, column: 1}, 77 | buffer, 78 | trailing_ws 79 | ) 80 | end 81 | 82 | <> when c in @whitespaces -> 83 | trailing_ws = [c | trailing_ws] 84 | parse_raw_body(%{context | rest: rest, column: context.column + 1}, buffer, trailing_ws) 85 | 86 | "" -> 87 | {:error, "Raw tag not terminated", %{line: context.line, column: context.column}} 88 | 89 | <> -> 90 | buffer = [c | trailing_ws ++ buffer] 91 | parse_raw_body(%{context | rest: rest, column: context.column + 1}, buffer, []) 92 | end 93 | end 94 | 95 | defimpl Solid.Renderable do 96 | def render(tag, context, _options) do 97 | {tag.text, context} 98 | end 99 | end 100 | 101 | defimpl Solid.Block do 102 | def blank?(tag) do 103 | String.trim(tag.text) == "" 104 | end 105 | end 106 | end 107 | -------------------------------------------------------------------------------- /lib/solid/condition_expression.ex: -------------------------------------------------------------------------------- 1 | defmodule Solid.ConditionExpression do 2 | alias Solid.{Argument, BinaryCondition, Context, Lexer, UnaryCondition} 3 | 4 | @type condition :: BinaryCondition.t() | UnaryCondition.t() 5 | 6 | @spec parse(Lexer.tokens()) :: {:ok, condition} | {:error, reason :: term, Lexer.loc()} 7 | def parse(tokens) do 8 | with {:ok, first_argument, rest} <- Argument.parse(tokens) do 9 | case rest do 10 | [{:end, _}] -> 11 | {:ok, %Solid.UnaryCondition{argument: first_argument, loc: first_argument.loc}} 12 | 13 | [{:identifier, _, relation} | rest] when relation in ["and", "or"] -> 14 | with {:ok, child_condition} <- parse(rest) do 15 | {:ok, 16 | %Solid.UnaryCondition{ 17 | argument: first_argument, 18 | loc: first_argument.loc, 19 | child_condition: {String.to_atom(relation), child_condition} 20 | }} 21 | end 22 | 23 | [{:comparison, _, operator} | rest] -> 24 | with {:ok, second_argument, rest} <- Argument.parse(rest) do 25 | case rest do 26 | [{:end, _}] -> 27 | {:ok, 28 | %Solid.BinaryCondition{ 29 | left_argument: first_argument, 30 | operator: operator, 31 | right_argument: second_argument, 32 | loc: first_argument.loc 33 | }} 34 | 35 | [{:identifier, _, relation} | rest] when relation in ["and", "or"] -> 36 | with {:ok, child_condition} <- parse(rest) do 37 | {:ok, 38 | %Solid.BinaryCondition{ 39 | left_argument: first_argument, 40 | operator: operator, 41 | right_argument: second_argument, 42 | loc: first_argument.loc, 43 | child_condition: {String.to_atom(relation), child_condition} 44 | }} 45 | end 46 | 47 | _ -> 48 | {:error, "Expected condition", Solid.Parser.meta_head(rest)} 49 | end 50 | end 51 | 52 | _ -> 53 | {:error, "Expected Condition", Solid.Parser.meta_head(rest)} 54 | end 55 | end 56 | end 57 | 58 | @spec eval(condition, Context.t(), keyword) :: 59 | {:ok, boolean, Context.t()} | {:error, Exception.t(), Context.t()} 60 | def eval(%BinaryCondition{} = condition, context, options) do 61 | {:ok, left_argument, context} = Argument.get(condition.left_argument, context, [], options) 62 | {:ok, right_argument, context} = Argument.get(condition.right_argument, context, [], options) 63 | 64 | case BinaryCondition.eval({left_argument, condition.operator, right_argument}) do 65 | {:ok, result} -> eval_child_condition(result, condition, context, options) 66 | {:error, reason} -> {:error, build_error(reason, condition.loc), context} 67 | end 68 | end 69 | 70 | def eval(%UnaryCondition{} = condition, context, options) do 71 | {:ok, argument, context} = Argument.get(condition.argument, context, [], options) 72 | 73 | UnaryCondition.eval(argument) 74 | |> eval_child_condition(condition, context, options) 75 | end 76 | 77 | defp eval_child_condition(left_side, condition, context, options) do 78 | case condition.child_condition do 79 | {:and, child_condition} -> 80 | with {:ok, result, context} <- eval(child_condition, context, options) do 81 | {:ok, left_side and result, context} 82 | end 83 | 84 | {:or, child_condition} -> 85 | with {:ok, result, context} = eval(child_condition, context, options) do 86 | {:ok, left_side or result, context} 87 | end 88 | 89 | nil -> 90 | {:ok, left_side, context} 91 | end 92 | end 93 | 94 | defp build_error(reason, loc), do: %Solid.ArgumentError{message: reason, loc: loc} 95 | end 96 | -------------------------------------------------------------------------------- /test/solid/integration/scenarios/if/input.liquid: -------------------------------------------------------------------------------- 1 | contains 2 | {% if 1 contains '1' %}true{% else %}false{% endif %} 3 | {% if 1 contains 1 %}true{% else %}false{% endif %} 4 | {% if '123' contains 1 %}true{% else %}false{% endif %} 5 | 6 | comparison on different types 7 | {% if '2' > 1 %}true{% else %}false{% endif %} 8 | {% if 1 > '2' %}true{% else %}false{% endif %} 9 | {% if 1.0 > '2' %}true{% else %}false{% endif %} 10 | {% if '2' == 1.1 %}true{% else %}false{% endif %} 11 | {% if null > 2 %}true{% else %}false{% endif %} 12 | {% if nil > 2 %}true{% else %}false{% endif %} 13 | {% if 2 > nil %}true{% else %}false{% endif %} 14 | {% if '1' == 1 %}true{% else %}false{% endif %} 15 | {% if (1..3) == (1..3) %}true{% else %}false{% endif %} 16 | 17 | comparison on hashes 18 | {% if empty_hash > 2 %}true{% else %}false{% endif %} 19 | {% if empty_hash < 2 %}true{% else %}false{% endif %} 20 | {% if empty_hash == empty %}TRUE{% else %}FALSE{% endif %} 21 | {% if empty == empty_hash %}TRUE{% else %}FALSE{% endif %} 22 | {% if empty_hash > empty %}true{% else %}false{% endif %} 23 | {% if empty_hash < empty %}true{% else %}false{% endif %} 24 | {% if empty > empty_hash %}true{% else %}false{% endif %} 25 | {% if empty < empty_hash %}true{% else %}false{% endif %} 26 | 27 | {% if empty_hash != empty %}TRUE{% else %}FALSE{% endif %} 28 | {% if empty != empty_hash %}TRUE{% else %}FALSE{% endif %} 29 | {% if empty_hash <> empty %}TRUE{% else %}FALSE{% endif %} 30 | {% if empty <> empty_hash %}TRUE{% else %}FALSE{% endif %} 31 | 32 | {% if var == nil %} true {% else %} false {% endif %} 33 | {% if var != nil %} true {% else %} false {% endif %} 34 | 35 | {% if var == null %} true {% else %} false {% endif %} 36 | {% if var == null %} true {% else %} false {% endif %} 37 | {% if var2 != nil %} true {% else %} false {% endif %} 38 | {% if var2 != null %} true {% else %} false {% endif %} 39 | 40 | {% if 0 >= 0 %} true {% else %} false {% endif %} 41 | 42 | {% if 0 <= null %} true {% else %} false {% endif %} 43 | 44 | {% if 0 >= 0 %} true {% else %} false {% endif %} 45 | 46 | {% if 0 <= null %} true {% else %} false {% endif %} 47 | 48 | {% if null <= 0 %} true {% else %} false {% endif %} 49 | 50 | {% if null >= 0 %} true {% else %} false {% endif %} 51 | {% if null >= 1 %} true {% else %} false {% endif %} 52 | {% if null >= 1.0 %} true {% else %} false {% endif %} 53 | 54 | {% if 55 | 0 >= 0 %} true {% else 56 | %} false {% endif 57 | %} 58 | 59 | {% if array contains 1 %} true {% else %} false {% endif %} 60 | {% if 1 contains array %} true {% else %} false {% endif %} 61 | 62 | {% if string-array[1] == "b" %} 63 | letter b 64 | {% endif %} 65 | 66 | {% if 2 == 2 and 1 == 2 and 1 != 1 or 1 == 1 %} 67 | This evaluates to false, since the tags are checked like this: 68 | 69 | true and (false and (false or true)) 70 | true and (false and true) 71 | true and false 72 | false 73 | {% endif %} 74 | 75 | {% if pages["about-us"].title == "About Us" %} 76 | True! 77 | {% else %} 78 | False! 79 | {% endif %} 80 | 81 | {% if var == -1 %} 82 | equal 83 | {% else %} 84 | not equal 85 | {% endif %} 86 | 87 | {% if var3 %} 88 | true 89 | {% else %} 90 | false 91 | {% endif %} 92 | 93 | {% assign var3 = true %} 94 | 95 | {% if var3 %} 96 | true 97 | {% else %} 98 | false 99 | {% endif %} 100 | 101 | {% if site.pages.size > 4 %} 102 | This is a big website: {{ site.size }} pages and {{ site.page_refs.size }} refs 103 | {% endif %} 104 | 105 | {% liquid 106 | if true 107 | if true 108 | echo 'deep true' 109 | endif 110 | endif 111 | %} 112 | 113 | {% if array == empty %}empty{% else %}not empty{% endif %} 114 | {% if empty == array %}empty{% else %}not empty{% endif %} 115 | {% if array == blank %}blank{% else %}not blank{% endif %} 116 | {% if blank == array %}blank{% else %}not blank{% endif %} 117 | 118 | Empty bodies 119 | {% if true %} {% elsif false %} {% else %} {% endif %} 120 | {% if false %} {% elsif true %} {% else %} {% endif %} 121 | {% if true %} {% comment %} this is empty {% endcomment %} {% endif %} 122 | 123 | Extra blocks 124 | {% if false %}if{% else %}else{% elsif true %}elsif{% endif %} 125 | {% comment %}{% if false %}if{% else %}else1{% else %}else2{% endif %} {% endcomment %} 126 | 127 | -------------------------------------------------------------------------------- /lib/solid/variable.ex: -------------------------------------------------------------------------------- 1 | defmodule Solid.Variable do 2 | alias Solid.Parser.Loc 3 | alias Solid.{AccessLiteral, AccessVariable} 4 | alias Solid.Literal 5 | 6 | @enforce_keys [:loc, :identifier, :accesses, :original_name] 7 | defstruct [:loc, :identifier, :accesses, :original_name] 8 | 9 | @type accesses :: [AccessVariable | AccessLiteral] 10 | @type t :: %__MODULE__{loc: Solid.Parser.Loc.t(), identifier: binary | nil, accesses: accesses} 11 | 12 | defimpl String.Chars do 13 | def to_string(variable), do: variable.original_name 14 | end 15 | 16 | @literals ~w(empty nil false true blank) 17 | 18 | @spec parse(Solid.Lexer.tokens()) :: 19 | {:ok, t | Literal.t(), Solid.Lexer.tokens()} | {:error, binary, Solid.Lexer.loc()} 20 | def parse(tokens) do 21 | case tokens do 22 | [{:identifier, meta, identifier} | rest] -> 23 | do_parse_identifier(identifier, meta, rest) 24 | 25 | [{:open_square, meta} | _] -> 26 | with {:ok, rest, accesses, accesses_original_name} <- access(tokens) do 27 | original_name = Enum.join(accesses_original_name) 28 | 29 | {:ok, 30 | %__MODULE__{ 31 | loc: struct!(Loc, meta), 32 | identifier: nil, 33 | accesses: accesses, 34 | original_name: original_name 35 | }, rest} 36 | else 37 | {:error, _, meta} -> 38 | {:error, "Argument expected", meta} 39 | end 40 | 41 | _ -> 42 | {:error, "Variable expected", Solid.Parser.meta_head(tokens)} 43 | end 44 | end 45 | 46 | defp do_parse_identifier(identifier, meta, rest) do 47 | with {:ok, rest, accesses, accesses_original_name} <- access(rest) do 48 | if identifier in @literals and accesses == [] do 49 | {:ok, %Literal{loc: struct!(Loc, meta), value: literal(identifier)}, rest} 50 | else 51 | original_name = "#{identifier}" <> Enum.join(accesses_original_name) 52 | 53 | {:ok, 54 | %__MODULE__{ 55 | loc: struct!(Loc, meta), 56 | identifier: identifier, 57 | accesses: accesses, 58 | original_name: original_name 59 | }, rest} 60 | end 61 | end 62 | end 63 | 64 | # Should return a literal ONLY if there is no access after. Must check if nil, true and false need this also 65 | defp literal(identifier) do 66 | case identifier do 67 | "nil" -> nil 68 | "true" -> true 69 | "false" -> false 70 | "empty" -> %Literal.Empty{} 71 | "blank" -> "" 72 | end 73 | end 74 | 75 | defp access(tokens, accesses \\ [], original_name \\ []) do 76 | case tokens do 77 | [{:open_square, _}, {:integer, meta, number}, {:close_square, _} | rest] -> 78 | access = %AccessLiteral{loc: struct!(Loc, meta), value: number} 79 | access(rest, [access | accesses], ["[#{number}]" | original_name]) 80 | 81 | [{:open_square, _}, {:string, meta, string, quotes}, {:close_square, _} | rest] -> 82 | access = %AccessLiteral{loc: struct!(Loc, meta), value: string} 83 | quotes = IO.chardata_to_string([quotes]) 84 | access(rest, [access | accesses], ["[#{quotes}#{string}#{quotes}]" | original_name]) 85 | 86 | [{:open_square, _}, {:identifier, meta, _identifier} | _] -> 87 | with {:ok, variable, [{:close_square, _} | rest]} <- parse(tl(tokens)) do 88 | access = %AccessVariable{loc: struct!(Loc, meta), variable: variable} 89 | access(rest, [access | accesses], ["[#{variable.original_name}]" | original_name]) 90 | else 91 | {:ok, _, rest} -> 92 | {:error, "Argument access mal terminated", Solid.Parser.meta_head(rest)} 93 | 94 | error -> 95 | error 96 | end 97 | 98 | [{:dot, _}, {:identifier, meta, identifier} | rest] -> 99 | access = %AccessLiteral{loc: struct!(Loc, meta), value: identifier} 100 | access(rest, [access | accesses], [".#{identifier}" | original_name]) 101 | 102 | [{:open_square, meta} | _rest] -> 103 | {:error, "Argument access expected", meta} 104 | 105 | _ -> 106 | {:ok, tokens, Enum.reverse(accesses), Enum.reverse(original_name)} 107 | end 108 | end 109 | end 110 | -------------------------------------------------------------------------------- /lib/solid/file_system.ex: -------------------------------------------------------------------------------- 1 | defmodule Solid.FileSystem do 2 | @moduledoc """ 3 | A file system is a way to let your templates retrieve other templates for use with the include tag. 4 | 5 | You can implement a module that retrieve templates from the database, from the file system using a different path structure, you can provide them as hard-coded inline strings, or any manner that you see fit. 6 | 7 | You can add additional instance variables, arguments, or methods as needed. 8 | 9 | Example: 10 | 11 | ```elixir 12 | file_system = Solid.LocalFileSystem.new(template_path) 13 | text = Solid.render(template, file_system: {Solid.LocalFileSystem, file_system}) 14 | ``` 15 | 16 | This will render the template with a LocalFileSystem implementation rooted at 'template_path'. 17 | """ 18 | 19 | # Called by Solid to retrieve a template file 20 | @callback read_template_file(binary(), options :: any()) :: 21 | {:ok, String.t()} | {:error, Exception.t()} 22 | 23 | defmodule Error do 24 | @type t :: %__MODULE__{} 25 | defexception [:reason, :loc] 26 | 27 | def message(reason), do: reason 28 | end 29 | end 30 | 31 | defmodule Solid.BlankFileSystem do 32 | @moduledoc """ 33 | Default file system that return error on call 34 | """ 35 | @behaviour Solid.FileSystem 36 | 37 | @impl true 38 | def read_template_file(_template_path, _opts) do 39 | {:error, %Solid.FileSystem.Error{reason: "This solid context does not allow includes."}} 40 | end 41 | end 42 | 43 | defmodule Solid.LocalFileSystem do 44 | @moduledoc """ 45 | This implements an abstract file system which retrieves template files named in a manner similar to Liquid. 46 | ie. with the template name prefixed with an underscore. The extension ".liquid" is also added. 47 | 48 | For security reasons, template paths are only allowed to contain letters, numbers, and underscore. 49 | 50 | **Example:** 51 | 52 | file_system = Solid.LocalFileSystem.new("/some/path") 53 | 54 | Solid.LocalFileSystem.full_path(file_system, "mypartial") 55 | # => "/some/path/_mypartial.liquid" 56 | 57 | Solid.LocalFileSystem.full_path(file_system,"dir/mypartial") 58 | # => "/some/path/dir/_mypartial.liquid" 59 | 60 | Optionally in the second argument you can specify a custom pattern for template filenames. 61 | `%s` will be replaced with template basename 62 | Default pattern is "_%s.liquid". 63 | 64 | **Example:** 65 | 66 | file_system = Solid.LocalFileSystem.new("/some/path", "%s.html") 67 | 68 | Solid.LocalFileSystem.full_path( "index", file_system) 69 | # => "/some/path/index.html" 70 | 71 | """ 72 | defstruct [:root, :pattern] 73 | @behaviour Solid.FileSystem 74 | 75 | def new(root, pattern \\ "_%s.liquid") do 76 | %__MODULE__{ 77 | root: root, 78 | pattern: pattern 79 | } 80 | end 81 | 82 | @impl true 83 | def read_template_file(template_path, file_system) do 84 | with {:ok, full_path} <- full_path(template_path, file_system) do 85 | if File.exists?(full_path) do 86 | {:ok, File.read!(full_path)} 87 | else 88 | {:error, %Solid.FileSystem.Error{reason: "No such template '#{template_path}'"}} 89 | end 90 | end 91 | end 92 | 93 | defp full_path(template_path, file_system) do 94 | if String.match?(template_path, Regex.compile!("^[^./][a-zA-Z0-9_/-]+$")) do 95 | template_name = String.replace(file_system.pattern, "%s", Path.basename(template_path)) 96 | 97 | full_path = 98 | if String.contains?(template_path, "/") do 99 | file_system.root 100 | |> Path.join(Path.dirname(template_path)) 101 | |> Path.join(template_name) 102 | |> Path.expand() 103 | else 104 | file_system.root 105 | |> Path.join(template_name) 106 | |> Path.expand() 107 | end 108 | 109 | if String.starts_with?(full_path, Path.expand(file_system.root)) do 110 | {:ok, full_path} 111 | else 112 | {:error, 113 | %Solid.FileSystem.Error{reason: "Illegal template path '#{Path.expand(full_path)}'"}} 114 | end 115 | else 116 | {:error, %Solid.FileSystem.Error{reason: "Illegal template name '#{template_path}'"}} 117 | end 118 | end 119 | end 120 | -------------------------------------------------------------------------------- /test/solid/tags/assign_tag_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Solid.Tags.AssignTagTest do 2 | use ExUnit.Case, async: true 3 | alias Solid.Tags.AssignTag 4 | alias Solid.{Lexer, ParserContext} 5 | alias Solid.Parser.Loc 6 | 7 | defp parse(template) do 8 | context = %ParserContext{rest: template, line: 1, column: 1, mode: :normal} 9 | 10 | with {:ok, "assign", context} <- Lexer.tokenize_tag_start(context) do 11 | AssignTag.parse("assign", %Loc{line: 1, column: 1}, context) 12 | end 13 | end 14 | 15 | describe "parse/1" do 16 | test "basic" do 17 | template = ~s<{% assign var1 = "123" %}> 18 | 19 | assert parse(template) == 20 | {:ok, 21 | %AssignTag{ 22 | loc: %Loc{line: 1, column: 1}, 23 | argument: %Solid.Variable{ 24 | original_name: "var1", 25 | loc: %Loc{column: 11, line: 1}, 26 | identifier: "var1", 27 | accesses: [] 28 | }, 29 | object: %Solid.Object{ 30 | loc: %Loc{column: 18, line: 1}, 31 | argument: %Solid.Literal{ 32 | loc: %Loc{column: 18, line: 1}, 33 | value: "123" 34 | }, 35 | filters: [] 36 | } 37 | }, %Solid.ParserContext{rest: "", line: 1, column: 26, mode: :normal}} 38 | end 39 | 40 | test "with a filter" do 41 | template = ~s<{% assign var1 = var2 | default: 3 %}> 42 | 43 | assert parse(template) == 44 | { 45 | :ok, 46 | %AssignTag{ 47 | loc: %Loc{column: 1, line: 1}, 48 | object: %Solid.Object{ 49 | argument: %Solid.Variable{ 50 | original_name: "var2", 51 | loc: %Loc{column: 18, line: 1}, 52 | accesses: [], 53 | identifier: "var2" 54 | }, 55 | filters: [ 56 | %Solid.Filter{ 57 | loc: %Loc{line: 1, column: 25}, 58 | function: "default", 59 | positional_arguments: [ 60 | %Solid.Literal{ 61 | loc: %Loc{column: 34, line: 1}, 62 | value: 3 63 | } 64 | ], 65 | named_arguments: %{} 66 | } 67 | ], 68 | loc: %Loc{column: 18, line: 1} 69 | }, 70 | argument: %Solid.Variable{ 71 | original_name: "var1", 72 | accesses: [], 73 | identifier: "var1", 74 | loc: %Loc{column: 11, line: 1} 75 | } 76 | }, 77 | %Solid.ParserContext{ 78 | column: 38, 79 | line: 1, 80 | mode: :normal, 81 | rest: "" 82 | } 83 | } 84 | end 85 | 86 | test "error missing variable" do 87 | template = ~s<{% assign %}> 88 | assert parse(template) == {:error, "Argument expected", %{line: 1, column: 11}} 89 | end 90 | 91 | test "error extra tokens" do 92 | template = ~s<{% assign var1 = 123 = %}> 93 | assert parse(template) == {:error, "Unexpected token", %{line: 1, column: 22}} 94 | end 95 | 96 | test "error unexpected operator" do 97 | template = ~s<{% assign x = 1 - 2 %}{{ x }}> 98 | 99 | assert parse(template) == {:error, "Unexpected character '-'", %{line: 1, column: 17}} 100 | end 101 | end 102 | 103 | describe "Renderable impl" do 104 | test "assign assigns" do 105 | template = ~s<{% assign var1 = "123" %}> 106 | context = %Solid.Context{} 107 | 108 | {:ok, tag, _rest} = parse(template) 109 | 110 | assert Solid.Renderable.render(tag, context, []) == { 111 | [], 112 | %Solid.Context{ 113 | vars: %{"var1" => "123"} 114 | } 115 | } 116 | end 117 | 118 | test "assign with accesses" do 119 | template = ~s<{% assign var[1]['abc'] = "123" %}> 120 | context = %Solid.Context{} 121 | 122 | {:ok, tag, _rest} = parse(template) 123 | 124 | assert Solid.Renderable.render(tag, context, []) == { 125 | [], 126 | %Solid.Context{ 127 | vars: %{"var[1]['abc']" => "123"} 128 | } 129 | } 130 | end 131 | end 132 | end 133 | -------------------------------------------------------------------------------- /test/solid/condition_expression_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Solid.ConditionExpressionTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias Solid.{BinaryCondition, ConditionExpression, UnaryCondition} 5 | 6 | alias Solid.Parser.Loc 7 | 8 | defp parse(template) do 9 | context = %Solid.ParserContext{rest: "{{#{template}}}", line: 1, column: 1, mode: :normal} 10 | {:ok, tokens, _context} = Solid.Lexer.tokenize_object(context) 11 | ConditionExpression.parse(tokens) 12 | end 13 | 14 | describe "parse/1" do 15 | test "expression" do 16 | template = "var1 and false" 17 | 18 | assert parse(template) == { 19 | :ok, 20 | %Solid.UnaryCondition{ 21 | argument: %Solid.Variable{ 22 | original_name: "var1", 23 | accesses: [], 24 | identifier: "var1", 25 | loc: %Solid.Parser.Loc{column: 3, line: 1} 26 | }, 27 | argument_filters: [], 28 | child_condition: { 29 | :and, 30 | %Solid.UnaryCondition{ 31 | argument: %Solid.Literal{ 32 | loc: %Solid.Parser.Loc{column: 13, line: 1}, 33 | value: false 34 | }, 35 | argument_filters: [], 36 | child_condition: nil, 37 | loc: %Solid.Parser.Loc{column: 13, line: 1} 38 | } 39 | }, 40 | loc: %Solid.Parser.Loc{column: 3, line: 1} 41 | } 42 | } 43 | end 44 | 45 | test "binary condition with unary child condition" do 46 | template = "true == 1 and false" 47 | 48 | assert parse(template) == { 49 | :ok, 50 | %BinaryCondition{ 51 | loc: %Loc{column: 3, line: 1}, 52 | child_condition: 53 | {:and, 54 | %UnaryCondition{ 55 | loc: %Loc{column: 17, line: 1}, 56 | child_condition: nil, 57 | argument: %Solid.Literal{ 58 | loc: %Loc{column: 17, line: 1}, 59 | value: false 60 | }, 61 | argument_filters: [] 62 | }}, 63 | left_argument: %Solid.Literal{ 64 | loc: %Loc{column: 3, line: 1}, 65 | value: true 66 | }, 67 | left_argument_filters: [], 68 | operator: :==, 69 | right_argument: %Solid.Literal{ 70 | loc: %Loc{column: 11, line: 1}, 71 | value: 1 72 | }, 73 | right_argument_filters: [] 74 | } 75 | } 76 | end 77 | 78 | test "unary condition with binary child condition" do 79 | template = "true and 1 == false" 80 | 81 | assert parse(template) == { 82 | :ok, 83 | %UnaryCondition{ 84 | loc: %Loc{column: 3, line: 1}, 85 | argument: %Solid.Literal{ 86 | loc: %Loc{column: 3, line: 1}, 87 | value: true 88 | }, 89 | argument_filters: [], 90 | child_condition: { 91 | :and, 92 | %BinaryCondition{ 93 | child_condition: nil, 94 | loc: %Loc{column: 12, line: 1}, 95 | left_argument: %Solid.Literal{ 96 | loc: %Loc{column: 12, line: 1}, 97 | value: 1 98 | }, 99 | left_argument_filters: [], 100 | operator: :==, 101 | right_argument: %Solid.Literal{ 102 | loc: %Loc{column: 17, line: 1}, 103 | value: false 104 | }, 105 | right_argument_filters: [] 106 | } 107 | } 108 | } 109 | } 110 | end 111 | 112 | test "empty tokens" do 113 | assert parse("") == {:error, "Argument expected", %{line: 1, column: 3}} 114 | end 115 | 116 | test "wrong condition" do 117 | assert parse("true an false") == {:error, "Expected Condition", %{column: 8, line: 1}} 118 | end 119 | end 120 | 121 | describe "eval/3" do 122 | test "execution in the right order" do 123 | template = "true and false and false or true" 124 | {:ok, condition} = parse(template) 125 | context = %Solid.Context{} 126 | 127 | assert ConditionExpression.eval(condition, context, []) == {:ok, false, context} 128 | end 129 | end 130 | end 131 | -------------------------------------------------------------------------------- /lib/solid/tags/case_tag.ex: -------------------------------------------------------------------------------- 1 | defmodule Solid.Tags.CaseTag do 2 | alias Solid.{Argument, Parser} 3 | 4 | @type t :: %__MODULE__{ 5 | loc: Parser.Loc.t(), 6 | argument: Argument.t(), 7 | cases: [{[Argument.t()] | :else, [Parser.entry()]}] 8 | } 9 | 10 | @enforce_keys [:loc, :argument, :cases] 11 | defstruct [:loc, :argument, :cases] 12 | 13 | @behaviour Solid.Tag 14 | 15 | @impl true 16 | def parse("case", loc, context) do 17 | with {:ok, tokens, context} <- Solid.Lexer.tokenize_tag_end(context), 18 | {:ok, argument, [{:end, _}]} <- Argument.parse(tokens), 19 | {:ok, cases, context} <- parse_cases(context) do 20 | {:ok, %__MODULE__{loc: loc, argument: argument, cases: cases}, context} 21 | else 22 | {:ok, _argument, rest} -> {:error, "Unexpected token", Parser.meta_head(rest)} 23 | {:error, reason, _rest, loc} -> {:error, reason, loc} 24 | error -> error 25 | end 26 | end 27 | 28 | defp parse_cases(context) do 29 | # We just want to parse whatever is after {% case %} and before the first when, else or endcase 30 | with {:ok, _, tag_name, tokens, context} <- 31 | Parser.parse_until(context, ~w(when else endcase), "Expected endcase") do 32 | do_parse_cases(tag_name, tokens, context, []) 33 | end 34 | end 35 | 36 | defp do_parse_cases("when", tokens, context, acc) do 37 | with {:ok, arguments} <- parse_arguments(tokens), 38 | {:ok, result, tag_name, tokens, context} <- 39 | Parser.parse_until(context, ~w(when else endcase), "Expected endcase") do 40 | do_parse_cases(tag_name, tokens, context, [ 41 | {arguments, Parser.remove_blank_text_if_blank_body(result)} | acc 42 | ]) 43 | end 44 | end 45 | 46 | defp do_parse_cases("else", tokens, context, acc) do 47 | with {:tokens, [{:end, _}]} <- {:tokens, tokens}, 48 | {:ok, result, tag_name, tokens, context} <- 49 | Parser.parse_until(context, ~w(when else endcase), "Expected endcase") do 50 | do_parse_cases(tag_name, tokens, context, [ 51 | {:else, Parser.remove_blank_text_if_blank_body(result)} | acc 52 | ]) 53 | else 54 | {:tokens, tokens} -> 55 | {:error, "Unexpected token on else", Parser.meta_head(tokens)} 56 | 57 | error -> 58 | error 59 | end 60 | end 61 | 62 | defp do_parse_cases("endcase", tokens, context, acc) do 63 | case tokens do 64 | [{:end, _}] -> {:ok, Enum.reverse(acc), context} 65 | _ -> {:error, "Unexpected token on endcase", Parser.meta_head(tokens)} 66 | end 67 | end 68 | 69 | defp parse_arguments(tokens, acc \\ []) do 70 | with {:ok, argument, tokens} <- Argument.parse(tokens) do 71 | case tokens do 72 | [{:comma, _} | tokens] -> parse_arguments(tokens, [argument | acc]) 73 | [{:identifier, _, "or"} | tokens] -> parse_arguments(tokens, [argument | acc]) 74 | [{:end, _}] -> {:ok, Enum.reverse([argument | acc])} 75 | _ -> {:error, "Expected ',' or 'or'", Parser.meta_head(tokens)} 76 | end 77 | end 78 | end 79 | 80 | defimpl Solid.Renderable do 81 | def render(tag, context, options) do 82 | {:ok, value, context} = Solid.Argument.get(tag.argument, context, [], options) 83 | 84 | {_, acc, context} = 85 | tag.cases 86 | |> Enum.reduce({{:else_block, true}, [], context}, fn 87 | {:else, result}, {{:else_block, true}, acc, context} -> 88 | {{:else_block, true}, [result | acc], context} 89 | 90 | {:else, _result}, {{:else_block, false}, acc, context} -> 91 | {{:else_block, false}, acc, context} 92 | 93 | {arguments, result}, {{:else_block, else_block}, acc, context} -> 94 | {{:else_block, else_block}, inner_acc, context} = 95 | Enum.reduce(arguments, {{:else_block, else_block}, [], context}, fn argument, 96 | {{:else_block, 97 | else_block}, 98 | inner_acc, 99 | context} -> 100 | {:ok, evaluated_argument, context} = 101 | Solid.Argument.get(argument, context, [], options) 102 | 103 | if evaluated_argument == value do 104 | {{:else_block, false}, [result | inner_acc], context} 105 | else 106 | {{:else_block, else_block}, inner_acc, context} 107 | end 108 | end) 109 | 110 | {{:else_block, else_block}, [inner_acc | acc], context} 111 | end) 112 | 113 | {Enum.reverse(List.flatten(acc)), context} 114 | end 115 | end 116 | end 117 | -------------------------------------------------------------------------------- /test/solid/integration/scenarios/for/input.liquid: -------------------------------------------------------------------------------- 1 | Offset - continue 2 | 3 | {% for item in evens limit: 3 %}${{ item }} {% endfor %} 4 | {% for item in evens offset: continue %}&{{ item }} {% endfor %} 5 | 6 | {% assign new = (1..6) %} 7 | {% for i in new limit: 2 %}{{ i }} {% endfor %} 8 | {% assign new = 'a,b,c,d,e,f' | split: ',' %} 9 | {% for i in new offset: continue %}{{ i }} {% endfor %} 10 | 11 | {% for i in evens limit: 666 %}first{{ i }} {% endfor %} 12 | {% for i in evens offset: continue %}second{{ item }} {% endfor %} 13 | 14 | {% for item in (1..6) limit: 3 %}${{ item }} {% endfor %} 15 | {% for item in (1..6) offset: continue %}&{{ item }} {% endfor %} 16 | {% for item in (1..6) offset: continue %}&{{ item }} {% endfor %} 17 | 18 | {% assign a_range = (1..6) %} 19 | {% for item in a_range limit: 3 %} {% if item == 2 %} {% break %} {% endif %} {{ item }} {% endfor %} 20 | {% for item in a_range offset: continue %} {{ item }} {% endfor %} 21 | 22 | {% assign range2 = (1..6) %} 23 | {% for item in range2 limit: 3 %} {{ item }} {% endfor %} 24 | {% for item in range2 reversed offset: continue %} {{ item }} {% endfor %} 25 | 26 | {% assign range3 = (1..6) %} 27 | {% for item in range3 reversed offset: continue %} {{ item }} {% endfor %} 28 | 29 | {% for x in array limit: 3 %}{{ x }} {% endfor %} 30 | {% for y in array limit: 1 offset: continue %}{{ y }} {% endfor %} 31 | {% for y in array offset: continue %}{{ y }} {% endfor %} 32 | 33 | 34 | Example: {% for value in var %}{% if value > 2 %}Got: {{ value }}{% endif %}{% endfor %} 35 | 36 | {% assign value = 12 %} 37 | {% for value in var %} 38 | Got: {{ value }} 39 | {% endfor %} 40 | {{ value }} 41 | 42 | {% for product in products %} 43 | {{ product.title }} 44 | {% else %} 45 | The collection is empty. 46 | {% endfor %} 47 | 48 | {% assign kitchen_products = products | where: "type", "kitchen" %} 49 | 50 | Kitchen products: 51 | {% for product in kitchen_products %} 52 | - {{ product.title }} 53 | {% endfor %} 54 | 55 | a 56 | {% for i in values %} 57 | 1 yo 3 -- 58 | {% if i == 4 %} {% break %} {% else %} {{ i }} {% endif %} 59 | {% endfor %} 60 | end for 61 | range 62 | {% for i in (3..5) %} 63 | {{ i }} 64 | {% endfor %} 65 | 66 | {% assign first = 3 %} 67 | {% assign last = 3 %} 68 | {% for i in (first..last) %} 69 | {{ i }} 70 | {% endfor %} 71 | 72 | {% assign first = 3 %} 73 | {% for i in (first..3) %} 74 | {{ i }} 75 | {% endfor %} 76 | 77 | {% for item in array reversed offset:3 limit:1%} 78 | {{ item }} 79 | {% endfor %} 80 | 81 | {% for item in array limit:2 %} 82 | {{ item }} 83 | {% endfor %} 84 | 85 | {% for item in array offset:3 %} 86 | {{ item }} 87 | {% else %} 88 | else! 89 | {% endfor %} 90 | 91 | {% for _ in (1..5) %} 92 | {% if forloop.first == true %} 93 | First time through! 94 | {% else %} 95 | Not the first time. 96 | {% endif %} 97 | {% endfor %} 98 | 99 | {% for _ in (1..5) %} 100 | {{ forloop.index }} 101 | {% endfor %} 102 | 103 | {% for _ in (1..5) %} 104 | {% if forloop.last == true %} 105 | This is the last iteration! 106 | {% else %} 107 | Keep going... 108 | {% endif %} 109 | {% endfor %} 110 | 111 | {% for i in (1..5) %} 112 | {% if forloop.first == true %} 113 |

This collection has {{ forloop.length }} items:

114 | {% endif %} 115 |

{{ i }}

116 | {% endfor %} 117 | 118 | {% for _ in (1..5) %} 119 | {{ forloop.rindex }} 120 | {% endfor %} 121 | 122 | {% for _ in (1..5) %} 123 | {{ forloop.rindex0 }} 124 | {% endfor %} 125 | 126 | nested forloop 127 | 128 | {% for i in (1..2)%}{% for j in (1..3) %}i = {{ i }} j = {{ j }} {% endfor %}parentloop.index = {{ forloop.parentloop.index }}{% endfor %} 129 | 130 | {% for _ in (1..2) %} {% for _ in (1..2) %} inner.index0 = {{ forloop.index0 }} {% endfor %} outer.index0 = {{ forloop.index0 }} {% endfor %} 131 | 132 | {% for forloop in (1..5) %} 133 | {% for _ in (1..2) %} 134 | {{ forloop.index0 }} 135 | {% endfor %} 136 | {{ forloop.index0 }} 137 | {% endfor %} 138 | 139 | {% if enabled? %} 140 | ON 141 | {% else %} 142 | OFF 143 | {% endif %} 144 | 145 | {% if enabled2? == false %} 146 | ON 147 | {% else %} 148 | OFF 149 | {% endif %} 150 | 151 | forloop 152 | 153 | {%- for inner in outer -%} 154 | {%- for i in inner -%} 155 | {{- forloop.parentloop.index }}-{{ forloop.index -}} 156 | {%- endfor -%} 157 | {%- endfor -%} 158 | 159 | {% for tag in item. labels limit:1 %}{{ forloop.name }}{% endfor %} 160 | {% for tag in item[ 'labels' ] limit:1 %}{{ forloop.name }}{% endfor %} 161 | {% for tag in item[ "labels" ] limit:1 %}{{ forloop.name }}{% endfor %} 162 | {% for tag in item[ "labels" ] limit:1 %}{{ forloop.name }}{% endfor %} 163 | {% for tag in (0..5) limit:1 %}{{ forloop.name }}{% endfor %} 164 | {{ forloop.name }} 165 | 166 | Range 167 | 168 | {% for i in string %}{{ i }} - {% endfor %} 169 | {% for i in ('1'..'2') %} {{ i }} {% endfor %} 170 | {% for i in (1..'foo') %} {{ i }} {% endfor %} 171 | {% for i in (1..5) offset: '3' %} {{ i }} {% endfor %} 172 | {% for i in (1..5) limit: '3' %} {{ i }} {% endfor %} 173 | Offset error 174 | {% for i in (1..4) offset: 'foo' %}{{ i }} {% endfor %} 175 | {% for i in evens offset: 3 %} {{ i }} {% endfor %} 176 | 177 | Hash 178 | 179 | {% for item in collection %}{{ item[0] }} {{ item[1] }} {% endfor %} 180 | -------------------------------------------------------------------------------- /lib/solid/context.ex: -------------------------------------------------------------------------------- 1 | defmodule Solid.Context do 2 | alias Solid.{AccessLiteral, AccessVariable, Argument, Literal, Variable} 3 | 4 | defstruct vars: %{}, 5 | counter_vars: %{}, 6 | iteration_vars: %{}, 7 | cycle_state: %{}, 8 | registers: %{}, 9 | errors: [], 10 | matcher_module: Solid.Matcher 11 | 12 | @type t :: %__MODULE__{ 13 | vars: map, 14 | counter_vars: map, 15 | iteration_vars: %{optional(String.t()) => term}, 16 | cycle_state: map, 17 | registers: map, 18 | errors: Solid.errors(), 19 | matcher_module: module 20 | } 21 | @type scope :: :counter_vars | :vars | :iteration_vars 22 | 23 | def put_errors(context, errors) when is_list(errors) do 24 | %{context | errors: errors ++ context.errors} 25 | end 26 | 27 | def put_errors(context, error) do 28 | %{context | errors: [error | context.errors]} 29 | end 30 | 31 | @doc """ 32 | Get data from context respecting the provided scope order. 33 | 34 | Possible scope values: :counter_vars, :vars or :iteration_vars 35 | """ 36 | @spec get_in(t, Variable.t(), [scope], keyword) :: 37 | {:ok, term, t} | {:error, {:not_found, [term()]}, t} 38 | def get_in(context, variable, scopes, opts \\ []) do 39 | {keys, context} = 40 | Enum.reduce(variable.accesses, {[], context}, fn access, {keys, context} -> 41 | case access do 42 | %AccessLiteral{value: value} -> 43 | {[value | keys], context} 44 | 45 | %AccessVariable{variable: access_variable} -> 46 | {:ok, v, context} = Argument.get(access_variable, context, [], opts) 47 | 48 | {[v | keys], context} 49 | end 50 | end) 51 | 52 | # This exists here for the case when there is no initial identifier like: 53 | # {{ [foo] }} 54 | # In this case we start directly on the context vars 55 | keys = 56 | if variable.identifier do 57 | [variable.identifier | Enum.reverse(keys)] 58 | else 59 | Enum.reverse(keys) 60 | end 61 | 62 | result = get_from_scope(context, scopes, keys) 63 | 64 | Tuple.insert_at(result, 2, context) 65 | end 66 | 67 | @spec get_counter(t, [String.t()]) :: term | nil 68 | def get_counter(context, name) do 69 | case get_from_scope(context, :counter_vars, name) do 70 | {:ok, value} -> value 71 | {:error, :not_found} -> nil 72 | end 73 | end 74 | 75 | defp cycle_slug(%Literal{value: value}), do: "l:#{value}" 76 | 77 | defp cycle_slug(%Variable{} = variable) do 78 | # Using inspect here to include Parser.Loc and ensure variables are always different because that's how liquid treats it 79 | "v:#{inspect(variable)}-" <> cycle_slug(variable.accesses) 80 | end 81 | 82 | defp cycle_slug(%AccessLiteral{value: value}), do: "al:#{value}" 83 | defp cycle_slug(%AccessVariable{variable: variable}), do: "av:#{variable}" 84 | 85 | defp cycle_slug(list) when is_list(list) do 86 | list 87 | |> Enum.map(&cycle_slug/1) 88 | |> Enum.join(",") 89 | end 90 | 91 | @doc """ 92 | Find the current value that `cycle` must return 93 | """ 94 | @spec run_cycle(t(), name :: Argument.t() | nil, values :: [Argument.t()]) :: 95 | {t(), Argument.t() | nil} 96 | def run_cycle(%__MODULE__{cycle_state: cycle_state} = context, name, values) do 97 | {name, context} = 98 | if name do 99 | # Liquid gem seems to evaluate when it's properly named 100 | {:ok, value, context} = Argument.get(name, context, []) 101 | {value, context} 102 | else 103 | {cycle_slug(name || values), context} 104 | end 105 | 106 | case cycle_state[name] do 107 | {current_index, cycle_map} -> 108 | limit = map_size(cycle_map) 109 | next_index = if current_index + 1 < limit, do: current_index + 1, else: 0 110 | 111 | cycle_map = cycle_to_map(values) 112 | cycle_state = %{context.cycle_state | name => {next_index, cycle_map}} 113 | 114 | {%{context | cycle_state: cycle_state}, cycle_map[next_index]} 115 | 116 | nil -> 117 | cycle_map = cycle_to_map(values) 118 | current_index = 0 119 | 120 | {%{context | cycle_state: Map.put_new(cycle_state, name, {current_index, cycle_map})}, 121 | cycle_map[current_index]} 122 | end 123 | end 124 | 125 | defp cycle_to_map(cycle) do 126 | cycle 127 | |> Enum.with_index() 128 | |> Enum.into(%{}, fn {value, index} -> {index, value} end) 129 | end 130 | 131 | defp get_from_scope(context, scopes, variable) when is_list(scopes) do 132 | scopes 133 | |> Enum.reverse() 134 | |> Enum.map(&get_from_scope(context, &1, variable)) 135 | |> Enum.reduce({:error, {:not_found, variable}}, fn 136 | {:ok, nil}, acc = {:ok, _} -> acc 137 | value = {:ok, _}, _acc -> value 138 | _value, acc -> acc 139 | end) 140 | end 141 | 142 | defp get_from_scope(context, :vars, variable) do 143 | context.matcher_module.match(context.vars, variable) 144 | end 145 | 146 | defp get_from_scope(context, :counter_vars, variable) do 147 | context.matcher_module.match(context.counter_vars, variable) 148 | end 149 | 150 | defp get_from_scope(context, :iteration_vars, variable) do 151 | context.matcher_module.match(context.iteration_vars, variable) 152 | end 153 | end 154 | -------------------------------------------------------------------------------- /test/solid/tags/tablerow_tag_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Solid.Tags.TablerowTagTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias Solid.Tags.TablerowTag 5 | alias Solid.{Lexer, ParserContext} 6 | alias Solid.Parser.Loc 7 | 8 | defp parse(template) do 9 | context = %ParserContext{rest: template, line: 1, column: 1, mode: :normal} 10 | 11 | with {:ok, tag_name, context} <- Lexer.tokenize_tag_start(context) do 12 | TablerowTag.parse(tag_name, %Loc{line: 1, column: 1}, context) 13 | end 14 | end 15 | 16 | describe "parse/2" do 17 | test "tablerow" do 18 | template = """ 19 | {% tablerow product in products %} 20 | {{ product.title }} 21 | {% endtablerow %} 22 | """ 23 | 24 | assert parse(template) == { 25 | :ok, 26 | %TablerowTag{ 27 | loc: %Loc{line: 1, column: 1}, 28 | variable: %Solid.Variable{ 29 | loc: %Loc{line: 1, column: 13}, 30 | identifier: "product", 31 | original_name: "product", 32 | accesses: [] 33 | }, 34 | body: [ 35 | %Solid.Text{loc: %Loc{line: 1, column: 35}, text: "\n "}, 36 | %Solid.Object{ 37 | loc: %Loc{line: 2, column: 6}, 38 | argument: %Solid.Variable{ 39 | loc: %Loc{line: 2, column: 6}, 40 | identifier: "product", 41 | accesses: [ 42 | %Solid.AccessLiteral{ 43 | loc: %Loc{line: 2, column: 14}, 44 | value: "title" 45 | } 46 | ], 47 | original_name: "product.title" 48 | }, 49 | filters: [] 50 | }, 51 | %Solid.Text{loc: %Loc{line: 2, column: 22}, text: "\n"} 52 | ], 53 | parameters: %{}, 54 | enumerable: %Solid.Variable{ 55 | loc: %Loc{line: 1, column: 24}, 56 | identifier: "products", 57 | original_name: "products", 58 | accesses: [] 59 | } 60 | }, 61 | %Solid.ParserContext{line: 3, mode: :normal, column: 18, rest: "\n", tags: nil} 62 | } 63 | end 64 | 65 | test "range" do 66 | template = """ 67 | {% tablerow i in (1..5) %} 68 | {{ i }} 69 | {% endtablerow %} 70 | """ 71 | 72 | assert parse(template) == 73 | {:ok, 74 | %TablerowTag{ 75 | loc: %Loc{line: 1, column: 1}, 76 | variable: %Solid.Variable{ 77 | loc: %Loc{line: 1, column: 13}, 78 | identifier: "i", 79 | accesses: [], 80 | original_name: "i" 81 | }, 82 | enumerable: %Solid.Range{ 83 | loc: %Loc{line: 1, column: 18}, 84 | start: %Solid.Literal{loc: %Loc{line: 1, column: 19}, value: 1}, 85 | finish: %Solid.Literal{loc: %Loc{line: 1, column: 22}, value: 5} 86 | }, 87 | parameters: %{}, 88 | body: [ 89 | %Solid.Text{loc: %Loc{line: 1, column: 27}, text: "\n "}, 90 | %Solid.Object{ 91 | loc: %Loc{line: 2, column: 6}, 92 | argument: %Solid.Variable{ 93 | loc: %Loc{line: 2, column: 6}, 94 | identifier: "i", 95 | accesses: [], 96 | original_name: "i" 97 | }, 98 | filters: [] 99 | }, 100 | %Solid.Text{loc: %Loc{line: 2, column: 10}, text: "\n"} 101 | ] 102 | }, 103 | %Solid.ParserContext{rest: "\n", line: 3, column: 18, mode: :normal, tags: nil}} 104 | end 105 | 106 | test "params" do 107 | template = """ 108 | {% tablerow i in (1..5) offset: 1, limit: 2, cols: 3 %} 109 | {{ i }} 110 | {% endtablerow %} 111 | """ 112 | 113 | assert parse(template) == 114 | { 115 | :ok, 116 | %TablerowTag{ 117 | body: [ 118 | %Solid.Text{ 119 | loc: %Loc{column: 56, line: 1}, 120 | text: "\n " 121 | }, 122 | %Solid.Object{ 123 | argument: %Solid.Variable{ 124 | accesses: [], 125 | identifier: "i", 126 | loc: %Loc{column: 6, line: 2}, 127 | original_name: "i" 128 | }, 129 | filters: [], 130 | loc: %Loc{column: 6, line: 2} 131 | }, 132 | %Solid.Text{ 133 | loc: %Loc{column: 10, line: 2}, 134 | text: "\n" 135 | } 136 | ], 137 | enumerable: %Solid.Range{ 138 | finish: %Solid.Literal{ 139 | loc: %Loc{column: 22, line: 1}, 140 | value: 5 141 | }, 142 | loc: %Loc{column: 18, line: 1}, 143 | start: %Solid.Literal{ 144 | loc: %Loc{column: 19, line: 1}, 145 | value: 1 146 | } 147 | }, 148 | loc: %Loc{column: 1, line: 1}, 149 | parameters: %{ 150 | cols: %Solid.Literal{ 151 | value: 3, 152 | loc: %Loc{line: 1, column: 52} 153 | }, 154 | limit: %Solid.Literal{ 155 | value: 2, 156 | loc: %Loc{line: 1, column: 43} 157 | }, 158 | offset: %Solid.Literal{ 159 | value: 1, 160 | loc: %Loc{line: 1, column: 33} 161 | } 162 | }, 163 | variable: %Solid.Variable{ 164 | accesses: [], 165 | identifier: "i", 166 | loc: %Loc{column: 13, line: 1}, 167 | original_name: "i" 168 | } 169 | }, 170 | %Solid.ParserContext{ 171 | column: 18, 172 | line: 3, 173 | mode: :normal, 174 | rest: "\n", 175 | tags: nil 176 | } 177 | } 178 | end 179 | end 180 | end 181 | -------------------------------------------------------------------------------- /test/solid/variable_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Solid.VariableTest do 2 | use ExUnit.Case, async: true 3 | alias Solid.Variable 4 | alias Solid.Parser.Loc 5 | 6 | defp parse(template) do 7 | context = %Solid.ParserContext{rest: "{{#{template}}}", line: 1, column: 1, mode: :normal} 8 | {:ok, tokens, _context} = Solid.Lexer.tokenize_object(context) 9 | 10 | Variable.parse(tokens) 11 | end 12 | 13 | describe "to_string/1" do 14 | test "variable with accesses" do 15 | var = %Solid.Variable{ 16 | identifier: "var1", 17 | original_name: "var1[var2[\"var3\"]][\"var4\"]", 18 | accesses: [ 19 | %Solid.AccessVariable{ 20 | loc: %Loc{column: 8, line: 1}, 21 | variable: %Solid.Variable{ 22 | original_name: "var2[\"var3\"]", 23 | loc: %Loc{column: 8, line: 1}, 24 | identifier: "var2", 25 | accesses: [ 26 | %Solid.AccessLiteral{loc: %Loc{column: 13, line: 1}, value: "var3"} 27 | ] 28 | } 29 | }, 30 | %Solid.AccessLiteral{ 31 | loc: %Loc{column: 19, line: 1}, 32 | value: "var4" 33 | } 34 | ], 35 | loc: %Loc{column: 3, line: 1} 36 | } 37 | 38 | assert to_string(var) == "var1[var2[\"var3\"]][\"var4\"]" 39 | end 40 | end 41 | 42 | describe "parse/1" do 43 | test "variable" do 44 | template = "var123 rest" 45 | 46 | assert parse(template) == { 47 | :ok, 48 | %Variable{ 49 | loc: %Loc{column: 3, line: 1}, 50 | original_name: "var123", 51 | accesses: [], 52 | identifier: "var123" 53 | }, 54 | [ 55 | {:identifier, %{column: 10, line: 1}, "rest"}, 56 | {:end, %{line: 1, column: 14}} 57 | ] 58 | } 59 | end 60 | 61 | test "bracket variable" do 62 | template = "['a var'].foo }}" 63 | 64 | assert { 65 | :ok, 66 | %Variable{ 67 | accesses: [ 68 | %Solid.AccessLiteral{value: "a var"}, 69 | %Solid.AccessLiteral{value: "foo"} 70 | ], 71 | identifier: nil, 72 | original_name: "['a var'].foo" 73 | }, 74 | [end: %{column: 17, line: 1}] 75 | } = parse(template) 76 | end 77 | 78 | test "variable with accesses" do 79 | template = ~s{var1.var2["string"][123][var3]} 80 | 81 | assert parse(template) == { 82 | :ok, 83 | %Solid.Variable{ 84 | original_name: "var1.var2[\"string\"][123][var3]", 85 | accesses: [ 86 | %Solid.AccessLiteral{ 87 | loc: %Loc{column: 8, line: 1}, 88 | value: "var2" 89 | }, 90 | %Solid.AccessLiteral{ 91 | loc: %Loc{column: 13, line: 1}, 92 | value: "string" 93 | }, 94 | %Solid.AccessLiteral{ 95 | loc: %Loc{column: 23, line: 1}, 96 | value: 123 97 | }, 98 | %Solid.AccessVariable{ 99 | loc: %Loc{column: 28, line: 1}, 100 | variable: %Solid.Variable{ 101 | loc: %Loc{column: 28, line: 1}, 102 | original_name: "var3", 103 | identifier: "var3", 104 | accesses: [] 105 | } 106 | } 107 | ], 108 | identifier: "var1", 109 | loc: %Loc{column: 3, line: 1} 110 | }, 111 | [end: %{column: 33, line: 1}] 112 | } 113 | end 114 | 115 | test "variable with nested accesses" do 116 | template = "var1[var2.var3].var4" 117 | 118 | assert parse(template) == { 119 | :ok, 120 | %Solid.Variable{ 121 | original_name: "var1[var2.var3].var4", 122 | accesses: [ 123 | %Solid.AccessVariable{ 124 | loc: %Loc{column: 8, line: 1}, 125 | variable: %Solid.Variable{ 126 | original_name: "var2.var3", 127 | loc: %Loc{column: 8, line: 1}, 128 | identifier: "var2", 129 | accesses: [ 130 | %Solid.AccessLiteral{loc: %Loc{column: 13, line: 1}, value: "var3"} 131 | ] 132 | } 133 | }, 134 | %Solid.AccessLiteral{ 135 | loc: %Loc{column: 19, line: 1}, 136 | value: "var4" 137 | } 138 | ], 139 | identifier: "var1", 140 | loc: %Loc{column: 3, line: 1} 141 | }, 142 | [end: %{column: 23, line: 1}] 143 | } 144 | end 145 | 146 | test "nil" do 147 | assert parse("nil") == { 148 | :ok, 149 | %Solid.Literal{loc: %Loc{line: 1, column: 3}, value: nil}, 150 | [end: %{line: 1, column: 6}] 151 | } 152 | end 153 | 154 | test "empty" do 155 | assert parse("empty") == { 156 | :ok, 157 | %Solid.Literal{loc: %Loc{line: 1, column: 3}, value: %Solid.Literal.Empty{}}, 158 | [end: %{line: 1, column: 8}] 159 | } 160 | end 161 | 162 | test "true" do 163 | assert parse("true") == { 164 | :ok, 165 | %Solid.Literal{loc: %Loc{line: 1, column: 3}, value: true}, 166 | [end: %{line: 1, column: 7}] 167 | } 168 | end 169 | 170 | test "false" do 171 | assert parse("false") == { 172 | :ok, 173 | %Solid.Literal{loc: %Loc{line: 1, column: 3}, value: false}, 174 | [end: %{line: 1, column: 8}] 175 | } 176 | end 177 | 178 | test "blank" do 179 | assert parse("blank") == { 180 | :ok, 181 | %Solid.Literal{loc: %Loc{line: 1, column: 3}, value: ""}, 182 | [end: %{line: 1, column: 8}] 183 | } 184 | end 185 | 186 | test "empty tokens" do 187 | assert parse("") == {:error, "Variable expected", %{line: 1, column: 3}} 188 | end 189 | 190 | test "broken accesses" do 191 | template = "var1[var2" 192 | 193 | assert parse(template) == {:error, "Argument access mal terminated", %{column: 12, line: 1}} 194 | end 195 | 196 | test "broken nested accesses" do 197 | template = "var1[var2.var3.]" 198 | 199 | assert parse(template) == {:error, "Argument access mal terminated", %{column: 17, line: 1}} 200 | end 201 | end 202 | end 203 | -------------------------------------------------------------------------------- /lib/solid/tags/if_tag.ex: -------------------------------------------------------------------------------- 1 | defmodule Solid.Tags.IfTag do 2 | @moduledoc """ 3 | Handle if and unless tags 4 | """ 5 | @behaviour Solid.Tag 6 | 7 | alias Solid.{ConditionExpression, Parser.Loc, Parser} 8 | 9 | @enforce_keys [:loc, :tag_name, :body, :elsifs, :else_body, :condition] 10 | defstruct [:loc, :tag_name, :body, :elsifs, :else_body, :condition] 11 | 12 | @type t :: %__MODULE__{ 13 | loc: Loc.t(), 14 | tag_name: :if | :unless, 15 | elsifs: [{ConditionExpression.condition(), [Parser.entry()]}], 16 | body: [Parser.entry()], 17 | else_body: [Parser.entry()], 18 | condition: ConditionExpression.condition() 19 | } 20 | 21 | defp ignore_until_end("if", "endif", context), do: {:ok, context} 22 | defp ignore_until_end("unless", "endunless", context), do: {:ok, context} 23 | 24 | defp ignore_until_end(starting_tag_name, _tag_name, context) do 25 | tags = if starting_tag_name == "if", do: "endif", else: "endunless" 26 | 27 | case Parser.parse_until(context, tags, "Expected endif") do 28 | {:ok, _result, _tag_name, _tokens, context} -> 29 | {:ok, context} 30 | 31 | {:error, "Expected 'endif'", meta} -> 32 | {:error, "Expected '#{tags}'", meta} 33 | 34 | {:error, reason, meta} -> 35 | {:error, "Expected '#{tags}'. Got: #{reason}", meta} 36 | end 37 | end 38 | 39 | @impl true 40 | def parse(starting_tag_name, loc, context) when starting_tag_name in ["if", "unless"] do 41 | with {:ok, tokens, context} <- Solid.Lexer.tokenize_tag_end(context), 42 | {:ok, condition} <- ConditionExpression.parse(tokens), 43 | {:ok, body, tag_name, tokens, context} <- parse_body(starting_tag_name, context), 44 | {:ok, elsifs, tag_name, context} <- 45 | parse_elsifs(starting_tag_name, tag_name, tokens, context), 46 | {:ok, else_body, tag_name, context} <- 47 | parse_else_body(starting_tag_name, tag_name, context), 48 | # Here we ignore until and endif or endunless is found ignoring extra 49 | # elses and elsifs 50 | {:ok, context} <- ignore_until_end(starting_tag_name, tag_name, context) do 51 | {:ok, 52 | %__MODULE__{ 53 | tag_name: String.to_existing_atom(starting_tag_name), 54 | body: Parser.remove_blank_text_if_blank_body(body), 55 | else_body: Parser.remove_blank_text_if_blank_body(else_body), 56 | elsifs: elsifs, 57 | condition: condition, 58 | loc: loc 59 | }, context} 60 | end 61 | end 62 | 63 | defp parse_body(starting_tag_name, context) do 64 | tags = if starting_tag_name == "if", do: ~w(elsif else endif), else: ~w(elsif else endunless) 65 | expected_end_tag = if starting_tag_name == "if", do: "endif", else: "endunless" 66 | 67 | case Parser.parse_until(context, tags, "Expected 'endif'") do 68 | {:ok, result, tag_name, tokens, context} -> 69 | {:ok, result, tag_name, tokens, context} 70 | 71 | {:error, "Expected 'endif'", meta} -> 72 | {:error, "Expected '#{expected_end_tag}'", meta} 73 | 74 | {:error, reason, meta} -> 75 | {:error, "Expected one of '#{Enum.join(tags, "', '")}' tags. Got: #{reason}", meta} 76 | end 77 | end 78 | 79 | defp parse_elsifs(starting_tag_name, tag_name, tokens, context, acc \\ []) 80 | 81 | defp parse_elsifs("if", "endif", _tokens, context, acc), 82 | do: {:ok, Enum.reverse(acc), "endif", context} 83 | 84 | defp parse_elsifs("unless", "endunless", _tokens, context, acc), 85 | do: {:ok, Enum.reverse(acc), "endunless", context} 86 | 87 | defp parse_elsifs(_starting_tag_name, "else", _tokens, context, acc), 88 | do: {:ok, Enum.reverse(acc), "else", context} 89 | 90 | defp parse_elsifs(starting_tag_name, "elsif", tokens, context, acc) do 91 | tags = if starting_tag_name == "if", do: ~w(else endif), else: ~w(else endunless) 92 | 93 | case Parser.maybe_tokenize_tag(tags, context) do 94 | {:tag, tag_name, _tokens, context} -> 95 | {:ok, Enum.reverse(acc), tag_name, context} 96 | 97 | _ -> 98 | with {:ok, condition} <- ConditionExpression.parse(tokens), 99 | {:ok, body, tag_name, tokens, context} <- parse_body(starting_tag_name, context) do 100 | parse_elsifs(starting_tag_name, tag_name, tokens, context, [ 101 | {condition, Parser.remove_blank_text_if_blank_body(body)} | acc 102 | ]) 103 | end 104 | end 105 | end 106 | 107 | defp parse_else_body("if", "endif", context), do: {:ok, [], "endif", context} 108 | defp parse_else_body("unless", "endunless", context), do: {:ok, [], "endunless", context} 109 | 110 | defp parse_else_body(starting_tag_name, "else", context) do 111 | tag = if starting_tag_name == "if", do: ~w(endif else elsif), else: ~w(endunless else elsif) 112 | expected_tag = if starting_tag_name == "if", do: "endif", else: "endunless" 113 | 114 | case Parser.parse_until(context, tag, "Expected 'endif'") do 115 | {:ok, result, tag_name, _tokens, context} -> 116 | {:ok, result, tag_name, context} 117 | 118 | {:error, "Expected 'endif'", meta} -> 119 | {:error, "Expected '#{expected_tag}'", meta} 120 | 121 | {:error, reason, meta} -> 122 | {:error, "Expected '#{expected_tag}' tag. Got: #{reason}", meta} 123 | end 124 | end 125 | 126 | defimpl Solid.Renderable do 127 | alias Solid.Tags.IfTag 128 | 129 | def render(tag, context, options) do 130 | eval_main_body!(tag, context, options) 131 | 132 | eval_elsifs!(tag.elsifs, context, options) 133 | 134 | if tag.else_body do 135 | {tag.else_body, context} 136 | else 137 | {[], context} 138 | end 139 | catch 140 | {:result, result, context} -> {result, context} 141 | end 142 | 143 | defp eval_main_body!(%IfTag{tag_name: :if} = tag, context, options) do 144 | case ConditionExpression.eval(tag.condition, context, options) do 145 | {:ok, result, context} -> 146 | if result, do: throw({:result, tag.body, context}) 147 | 148 | {:error, exception, context} -> 149 | return_error(exception, context) 150 | end 151 | end 152 | 153 | defp eval_main_body!(%IfTag{tag_name: :unless} = tag, context, options) do 154 | case ConditionExpression.eval(tag.condition, context, options) do 155 | {:ok, result, context} -> 156 | if !result, do: throw({:result, tag.body, context}) 157 | 158 | {:error, exception, context} -> 159 | return_error(exception, context) 160 | end 161 | end 162 | 163 | defp eval_elsifs!(elsifs, context, options) do 164 | Enum.each(elsifs, fn {condition, body} -> 165 | case ConditionExpression.eval(condition, context, options) do 166 | {:ok, result, context} -> 167 | if result, do: throw({:result, body, context}) 168 | 169 | {:error, exception, context} -> 170 | return_error(exception, context) 171 | end 172 | end) 173 | end 174 | 175 | defp return_error(exception, context) do 176 | context = Solid.Context.put_errors(context, exception) 177 | throw({:result, Exception.message(exception), context}) 178 | end 179 | end 180 | end 181 | --------------------------------------------------------------------------------