Current section

Files

Jump to
solid lib solid.ex
Raw

lib/solid.ex

defmodule Solid do
@moduledoc """
Solid is an implementation in Elixir of the [Liquid](https://shopify.github.io/liquid/) template language with
strict parsing.
"""
alias Solid.{Context, Object, Parser, Text}
@type errors :: [error]
@type error ::
Solid.UndefinedVariableError.t()
| Solid.UndefinedFilterError.t()
| Solid.ArgumentError.t()
| Solid.WrongFilterArityError.t()
| Solid.FileSystem.Error.t()
| Solid.TemplateError.t()
defmodule Template do
@type t :: %__MODULE__{parsed_template: Parser.parse_tree()}
@enforce_keys [:parsed_template]
defstruct [:parsed_template]
end
defmodule RenderError do
@type t :: %__MODULE__{message: binary, errors: Solid.errors(), result: iolist}
defexception [:message, :errors, :result]
@impl true
def message(exception) do
message = "#{length(exception.errors)} error(s) found while rendering"
errors =
exception.errors
|> Enum.map(&Exception.message/1)
|> Enum.join("\n")
message <> "\n" <> errors
end
end
defmodule ParserError do
@type t :: %__MODULE__{
reason: binary,
meta: %{line: pos_integer, column: pos_integer},
text: binary
}
defexception [:reason, :meta, :text]
@impl true
def message(%{text: text, reason: reason, meta: %{line: line, column: column}}) do
line_size = String.length(to_string(line))
"#{reason}\n#{line}: #{text}\n#{String.pad_leading("^", column + line_size + 2)}"
end
end
defmodule TemplateError do
@type t :: %__MODULE__{errors: [ParserError.t()]}
defexception [:errors]
@impl true
def message(exception) do
exception.errors
|> Enum.map(&Exception.message/1)
|> Enum.join("\n")
end
end
@doc """
It generates the compiled template
This function returns the compiled template or raises an error. Same options as `parse/2`
"""
def parse!(text, opts \\ []) do
case parse(text, opts) do
{:ok, template} -> template
{:error, template_error} -> raise template_error
end
end
@doc """
It generates the compiled template
This function returns `{:ok, template}` if successfully parses the template, `{:error, template_error}` otherwise
# Options
- `tags` - Override tags allowed during compilation. See `Solid.Tag.default_tags/0` for more information on the default set of tags
"""
@spec parse(binary, keyword) :: {:ok, Template.t()} | {:error, TemplateError.t()}
def parse(text, opts \\ []) do
with {:ok, parse_tree} <- Parser.parse(text, opts) do
{:ok, %Template{parsed_template: parse_tree}}
else
{:error, errors} ->
lines = String.splitter(text, "\n")
errors =
Enum.map(errors, fn {reason, meta} ->
%ParserError{text: Enum.at(lines, meta[:line] - 1), reason: reason, meta: meta}
end)
{:error, %TemplateError{errors: errors}}
end
end
@doc """
It renders the compiled template using a map with vars
Same options as `render/3`
"""
@spec render!(Template.t(), map, keyword) :: iolist | no_return
def render!(%Template{} = template, hash, options \\ []) do
case render(template, hash, options) do
# Ignore errors here unless `strict_variables` or `strict_filters` are used
{:ok, result, _error} ->
result
{:error, errors, result} ->
raise RenderError, errors: errors, result: result
end
end
@doc """
It renders the compiled template using a map with initial vars
## Options
- `file_system`: a tuple of {FileSystemModule, options}. If this option is not specified, `Solid` uses `Solid.BlankFileSystem` which returns an error when the `render` tag is used. `Solid.LocalFileSystem` can be used or a custom module may be implemented. See `Solid.FileSystem` for more details.
- `custom_filters`: a module name where additional filters are defined. The base filters (those from `Solid.StandardFilter`) still can be used, however, custom filters always take precedence. May also be a function that receives the filter name and the arguments and must return `{:ok, term} | :error`.
- `strict_variables`: if `true`, it collects an error when a variable is referenced in the template, but not given in the map
- `strict_filters`: if `true`, it collects an error when a filter is referenced in the template, but not built-in or provided via `custom_filters`
- `matcher_module`: a module to replace `Solid.Matcher` when resolving variables.
## Example
fs = Solid.LocalFileSystem.new("/path/to/template/dir/")
Solid.render(template, vars, [file_system: {Solid.LocalFileSystem, fs}])
"""
@spec render(Template.t(), map, keyword) ::
{:ok, result :: iolist, errors} | {:error, errors, partial_result :: iolist}
@spec render(Parser.parse_tree(), Context.t(), keyword) :: {iolist, Context.t()}
def render(template_or_text, values, options \\ [])
def render(%Template{parsed_template: parse_tree}, context = %Context{}, options) do
matcher_module = Keyword.get(options, :matcher_module, Solid.Matcher)
context = %{context | matcher_module: matcher_module}
{result, context} = render(parse_tree, context, options)
process_result(result, context, options)
catch
{exp, result, context} when exp in [:break_exp, :continue_exp] ->
process_result(result, context, options)
end
def render(%Template{} = template, hash, options) do
matcher_module = Keyword.get(options, :matcher_module, Solid.Matcher)
context = %Context{counter_vars: hash, matcher_module: matcher_module}
render(template, context, options)
end
def render(text, context = %Context{}, options) do
{result, context} =
Enum.reduce(List.wrap(text), {[], context}, fn entry, {acc, context} ->
try do
{result, context} = do_render(entry, context, options)
{[result | acc], context}
catch
{:break_exp, result, context} ->
throw({:break_exp, Enum.reverse([result | acc]), context})
{:continue_exp, result, context} ->
throw({:continue_exp, Enum.reverse([result | acc]), context})
end
end)
{Enum.reverse(result), context}
end
# Optimisation for object and text to avoid extra render calls
defp do_render(renderable, context, options)
when is_struct(renderable, Text) or is_struct(renderable, Object) do
Solid.Renderable.render(renderable, context, options)
end
defp do_render(tag, context, options) when is_struct(tag) do
{result, context} = Solid.Renderable.render(tag, context, options)
render(result, context, options)
end
defp do_render(iolist, context, _options) do
{iolist, context}
end
defp process_result(result, context, options) do
if strict_errors?(context.errors, options) do
{:error, Enum.reverse(context.errors), result}
else
{:ok, result, Enum.reverse(context.errors)}
end
end
defp strict_errors?(errors, options) do
variable_errors? = Enum.any?(errors, &match?(%Solid.UndefinedVariableError{}, &1))
filter_errors? = Enum.any?(errors, &match?(%Solid.UndefinedFilterError{}, &1))
(options[:strict_variables] == true && variable_errors?) ||
(options[:strict_filters] == true && filter_errors?)
end
end