├── 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 |
2 | - apples
3 | - oranges
4 | - peaches
5 | - plums
6 |
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 |
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 |
8 |
9 | {% else %}
10 |
11 |
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 |
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 |
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 |
--------------------------------------------------------------------------------