Solid
Solid is an implementation in Elixir of the Liquid template language with strict parsing.
Basic Usage
iex> template = "My name is {{ user.name }}"
iex> {:ok, template} = Solid.parse(template)
iex> Solid.render!(template, %{ "user" => %{ "name" => "José" } }) |> to_string
"My name is José"
Installation
The package can be installed with:
def deps do
[{:solid, "~> 1.0"}]
end
Custom tags
To implement a new tag you need to create a new module that implements the Tag behaviour. It must implement a parse/3 function that returns a struct that implements Solid.Renderable. Here is a simple example:
defmodule CurrentYear do
@enforce_keys [:loc]
defstruct [:loc]
@behaviour Solid.Tag
@impl true
def parse("get_current_year", loc, context) do
with {:ok, [{:end, _}], context} <- Solid.Lexer.tokenize_tag_end(context) do
{:ok, %__MODULE__{loc: loc}, context}
end
end
defimpl Solid.Renderable do
def render(_tag, context, _options) do
{[to_string(Date.utc_today().year)], context}
end
end
end
Now to use it simply pass a :tags option to Solid.parse/2 including your custom tag:
tags = Map.put(Solid.Tag.default_tags(), "get_current_year", CurrentYear)
Solid.parse!("{{ get_current_year }}", tags: tags)
One can also pass a subset of the default tags if a more restricted environment is necessary:
# No comment tags allowed
tags = Map.delete(Solid.Tag.default_tags(), "comment")
Solid.parse!("{% comment %} {% endcomment %}", tags: tags)
An error will be presented as comment is not part of the allowed tags:
** (Solid.TemplateError) Unexpected tag 'comment'
1: {% comment %} {% endcomment %}
^
Unexpected tag 'endcomment'
1: {% comment %} {% endcomment %}
^
(solid 1.0.0-rc1) lib/solid.ex:51: Solid.parse!/2
iex:2: (file)
Custom filters
While calling Solid.render one can pass a module with custom filters:
defmodule MyCustomFilters do
def add_one(x), do: x + 1
end
"{{ number | add_one }}"
|> Solid.parse!()
|> Solid.render!(%{ "number" => 41}, custom_filters: MyCustomFilters)
|> IO.puts()
# 42
Alternatively, you can pass a function to the custom_filters option. This allows
your filters to access predefined state. An example use-case could be to allow the
filters to render strings in the user's default locale (or to override it by
argument).
user_locale = "en"
"{{ number | format_number }}"
|> Solid.parse!()
|> Solid.render!(%{ "number" => 41}, custom_filters: fn
"format_number", [num] ->
{:ok, Cldr.Number.to_string(num, locale: user_locale)}
"format_number", [num, locale] ->
{:ok, Cldr.Number.to_string(num, locale: locale)}
_, _ ->
:error
end)
|> IO.puts()
The callback must return either {:ok, value} or :error.
Strict rendering
If there are any missing variables/filters and strict_variables: true or strict_filters: true are passed as options Solid.render/3 returns {:error, errors, result} where errors is the list of collected errors and result is the rendered template.
Solid.render!/3 raises if strict_variables: true is passed and there are missing variables.
Solid.render!/3 raises if strict_filters: true is passed and there are missing filters.
Caching
In order to cache render-ed templates, you can write your own cache adapter. It should implement behaviour Solid.Caching. By default it uses Solid.Caching.NoCache trivial adapter.
If you want to use for example Cachex for that such implemention would look like:
defmodule CachexCache do
@behaviour Solid.Caching
@impl true
def get(key) do
case Cachex.get(:your_cache_name, key) do
{_, nil} -> {:error, :not_found}
{:ok, value} -> {:ok, value}
{:error, error_msg} -> {:error, error_msg}
end
end
@impl true
def put(key, value) do
case Cachex.put(:my_cache, key, value) do
{:ok, true} -> :ok
{:error, error_msg} -> {:error, error_msg}
end
end
end
And then pass it as an option to render cache_module: CachexCache. Now while using {% render 'etc' %} this custom cache will be used
Using structs in context
In order to pass structs to context you need to implement protocol Solid.Matcher for that. That protocol consist of one function def match(data, keys). First argument is struct being provided and second is list of string, which are keys passed after . to the struct.
For example:
defmodule UserProfile do
defstruct [:full_name]
defimpl Solid.Matcher do
def match(user_profile, ["full_name"]), do: {:ok, user_profile.full_name}
end
end
defmodule User do
defstruct [:email]
def load_profile(%User{} = _user) do
# implementation omitted
%UserProfile{full_name: "John Doe"}
end
defimpl Solid.Matcher do
def match(user, ["email"]), do: {:ok, user.email}
def match(user, ["profile" | keys]), do: user |> User.load_profile() |> @protocol.match(keys)
end
end
template = ~s({{ user.email}}: {{ user.profile.full_name }})
context = %{
"user" => %User{email: "test@example.com"}
}
template |> Solid.parse!() |> Solid.render!(context) |> to_string()
# => test@example.com: John Doe
If the Solid.Matcher protocol is not enough one can provide a module like this:
defmodule MyMatcher do
def match(_data, _keys), do: {:ok, 42}
end
# ...
Solid.render!(template, %{"number" => 4}, matcher_module: MyMatcher)
The matcher is called with a scope and the variable's full path, e.g.
match(render_data, ["user", "email"]) for {{ user.email }}. Solid looks a name up in for
loop variables, then assigned variables, then the render data. The matcher is asked about loop
and assigned variables only when the scope holds that name, and about every name in the render
data, so it can resolve them its own way (e.g. atom keys or computed values).
Solid.Matcher reads size and first on a map as its size and first entry. A matcher that
hands the whole render data to Solid.Matcher therefore renders an undefined {{ size }} as the
size of the render data, and {% increment size %} starts counting from it, since
increment/decrement counters are kept alongside the render data. To avoid that, look the root
name up yourself and hand only the rest of the path to Solid.Matcher:
defmodule RootLookupMatcher do
def match(data, [root | keys]) when is_map(data) and not is_struct(data) do
case Map.fetch(data, root) do
{:ok, value} -> Solid.Matcher.match(value, keys)
:error -> {:error, :not_found}
end
end
def match(data, keys), do: Solid.Matcher.match(data, keys)
end
Sigil Support
Solid provides a ~LIQUID sigil for validating and compiling templates at compile time:
import Solid.Sigil
# Validates syntax at compile time
template = ~LIQUID"""
Hello, {{ name }}!
"""
# Use the compiled template
Solid.render!(template, %{"name" => "World"})
The sigil will raise helpful CompileError messages with line numbers and context when templates contain syntax errors. Experimental VSCode syntax highlighting is available with the Liquid Sigil extension.
Contributing
When adding new functionality or fixing bugs consider adding a new test case here inside test/solid/integration/scenarios. These scenarios are tested against the Ruby gem so we can try to stay as close as possible to the original implementation.
Copyright and License
Copyright (c) 2016-2025 Eduardo Gurgel Pinho
This work is free. You can redistribute it and/or modify it under the terms of the MIT License. See the LICENSE.md file for more details.