Solid is an implementation in Elixir of the template language Liquid. It uses nimble_parsec to generate the parser.
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é"
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.
The package can be installed with:
def deps do
[{:solid, "~> 0.14"}]
end
To implement a new tag you need to create a new module that implements the Tag
behaviour:
defmodule MyCustomTag do
import NimbleParsec
@behaviour Solid.Tag
@impl true
def spec(_parser) do
space = Solid.Parser.Literal.whitespace(min: 0)
ignore(string("{%"))
|> ignore(space)
|> ignore(string("my_tag"))
|> ignore(space)
|> ignore(string("%}"))
end
@impl true
def render(_tag, _context, _options) do
[text: "my first tag"]
end
end
spec
defines how to parse your tag;render
defines how to render your tag.
Now we need to add the tag to the parser
defmodule MyParser do
use Solid.Parser.Base, custom_tags: [MyCustomTag]
end
And finally pass the custom parser as an option:
"{% my_tag %}"
|> Solid.parse!(parser: MyParser)
|> Solid.render()
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
Extra options can be passed as last argument to custom filters if an extra argument is accepted:
defmodule MyCustomFilters do
def asset_url(path, opts) do
opts[:host] <> path
end
end
opts = [custom_filters: MyCustomFilters, host: "http://example.com"]
"{{ file_path | asset_url }}"
|> Solid.parse!()
|> Solid.render(%{ "file_path" => "/styles/app.css"}, opts)
|> IO.puts()
# http://example.com/styles/app.css
Solid.render/3
doesn't raise or return errors unless strict_variables: true
or strict_filters: true
are passed as options.
If there are any missing variables/filters 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.
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
.
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: "[email protected]"}
}
template |> Solid.parse!() |> Solid.render!(context) |> to_string()
# => [email protected]: John Doe
If the Solid.Matcher
protocol is not enough one can provide their own module like this:
defmodule MyMatcher do
def match(data, keys), do: {:ok, 42}
end
# ...
Solid.render(template, %{"number" => 4}, matcher_module: MyMatcher)
When adding new functionality or fixing bugs consider adding a new test case here inside test/cases
. These cases are tested against the Ruby gem so we can try to stay as close as possible to the original implementation.
- Integration tests using Liquid gem to build fixtures; #3
- All the standard filters #8
- Support to custom filters #11
- Tags (if, case, unless, etc)
- Boolean operators #2
- Whitespace control #10
Copyright (c) 2016-2022 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.