Solid is an implementation in Elixir of the Liquid template language with strict parsing.
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é"The package can be installed with:
defdepsdo[{:solid,"~> 1.0"}]endTo 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:
defmoduleCurrentYeardo@enforce_keys[:loc]defstruct[:loc]@behaviourSolid.Tag@impltruedefparse("get_current_year",loc,context)dowith{:ok,[{:end,_}],context}<-Solid.Lexer.tokenize_tag_end(context)do{:ok,%__MODULE__{loc: loc},context}endenddefimplSolid.Renderabledodefrender(_tag,context,_options)do{[to_string(Date.utc_today().year)],context}endendendNow 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 allowedtags=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%}^Unexpectedtag'endcomment'1: {%comment%}{%endcomment%}^(solid1.0.0-rc1) lib/solid.ex:51: Solid.parse!/2iex:2: (file)While calling Solid.render one can pass a module with custom filters:
defmoduleMyCustomFiltersdodefadd_one(x),do: x+1end"{{ number | add_one }}"|>Solid.parse!()|>Solid.render!(%{"number"=>41},custom_filters: MyCustomFilters)|>IO.puts()# 42Alternatively, 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)}_,_->:errorend)|>IO.puts()The callback must return either {:ok, value} or :error.
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.
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:
defmoduleCachexCachedo@behaviourSolid.Caching@impltruedefget(key)docaseCachex.get(:your_cache_name,key)do{_,nil}->{:error,:not_found}{:ok,value}->{:ok,value}{:error,error_msg}->{:error,error_msg}endend@impltruedefput(key,value)docaseCachex.put(:my_cache,key,value)do{:ok,true}->:ok{:error,error_msg}->{:error,error_msg}endendendAnd then pass it as an option to render cache_module: CachexCache. Now while using {% render 'etc' %} this custom cache will be used
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:
defmoduleUserProfiledodefstruct[:full_name]defimplSolid.Matcherdodefmatch(user_profile,["full_name"]),do: {:ok,user_profile.full_name}endenddefmoduleUserdodefstruct[:email]defload_profile(%User{}=_user)do# implementation omitted%UserProfile{full_name: "John Doe"}enddefimplSolid.Matcherdodefmatch(user,["email"]),do: {:ok,user.email}defmatch(user,["profile"|keys]),do: user|>User.load_profile()|>@protocol.match(keys)endendtemplate=~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 DoeIf the Solid.Matcher protocol is not enough one can provide a module like this:
defmoduleMyMatcherdodefmatch(_data,_keys),do: {:ok,42}end# ...Solid.render!(template,%{"number"=>4},matcher_module: MyMatcher)Solid provides a ~LIQUID sigil for validating and compiling templates at compile time:
importSolid.Sigil# Validates syntax at compile timetemplate=~LIQUID"""Hello, {{ name }}!"""# Use the compiled templateSolid.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.
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 (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.