Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,69 @@ def deps do
end
```

## Including Solid in your project

Solid comes with bundled matchers for the basic Elixir data types. To enable you to use your own
implementations instead, the bundled matchers aren't included by default. This is necessary,
because matchers implement a protocol, which means they can only be defined once for each type.

If you're happy to use the bundled matchers (the defaults), all you need to to do is to include a
call to `use Solid` or `use Solid.Matcher.Builtins` in your application code. Which one you choose
is up to you and effectually makes no difference. If you, on the other hand, want to replace some
of the defaults in Solid, there is some nuance.

By default, calling `use Solid` includes the bundled matchers and creates local methods for
`render/3`, `render!/3`, `parse/2` and `parse!/2` in your wrapper module. To select which
delegates are created, use the `:delegates` argument. The empty list or `false` omits all
delegates.

If you want to wrap Solid – it can be a convenient way to wrap calls to public methods for
e.g. always including specific options – and _also_ want to bring your own matchers, pass
the `:nomatchers` argument with any value.

```elixir
defmodule MyProject.Solid do
# `use Solid` by default creates delegates to `render/3`, `render!/3`, `parse/2` and
# `parse!/2` in your wrapper module. That means you can call `MyProject.Solid.render/3` etc,
# instead of using Solid methods directly. Using wrapper methods in your module can be a
# convenient way to e.g. including custom options. Always calling the public methods on
# your wrapper module makes it convenient to change from delegate to wrapped method later on.
#
# To pick which local methods are created, use the `delegates` argument.
# To not import the bundled matchers, use the `nomatchers` argument.

# wrap Solid, using all defaults
use Solid

# wrap Solid, but exclude the bundled matchers
use Solid, nomatchers: true

# wrap Solid, but exclude the delegated render methods
use Solid, delegates: [:parse, :parse!]
end
```

The `use Solid.Matcher.Builtins` macro has options for cherry-picking the bundled matchers,
refer to the module documentation for more details. Call `use Solid.Matcher.Builtins` in your
module alongside your custom matchers and you're good to go.

```elixir
defmodule MyProject.Solid.Matchers do
# `use Solid.Matcher.Builtins` includes the bundled matchers for basic Elixir data types.
# If you want to bring your own custom implementation, use the `except` and `only`
# arguments for cherry-picking and add your own implementations to this module.

# use all the bundled matchers
use Solid.Matcher.Builtins

# exclude the matcher for the Map type
use Solid.Matcher.Builtins, except: [:map]

# only include the matchers for the Any and Atom types
use Solid.Matcher.Builtins, only: [:any, :atom]
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:
Expand Down
52 changes: 52 additions & 0 deletions lib/solid.ex
Original file line number Diff line number Diff line change
@@ -1,4 +1,56 @@
defmodule Solid do
@doc """
It sets up Solid with built-in matchers for basic types and creates local delegates to `render`,
`render!`, `parse`, and `parse!` in your wrapper module.

Use this macro to include Solid in your project with a default configuration.

If you're not using the default configuration and wish to customise one or several of the basic
matchers, refer to the documentation in the `Solid.Matcher.Builtins` module.

Use the `:delegates` option to include only a subset of the delegates in your wrapper module. To
exclude all delegates, pass `false` or the empty list.

Use the `:nomatchers` option to exclude the bundled matchers.
"""
@all_delegates [:render, :render!, :parse, :parse!]
@type delegates :: :render | :render! | :parse | :parse!
@type option :: {:delegates, list(delegates()) | boolean()} | {:nomatchers, any()}
@type options :: list(option())
@spec __using__(options()) :: Macro.t()
defmacro __using__(options) do
delegates =
case Keyword.get(options, :delegates, @all_delegates) do
true -> @all_delegates
[_ | _] = v -> v
_ -> []
end

matchers = not Keyword.has_key?(options, :nomatchers)

quote do
if unquote(matchers) do
use Solid.Matcher.Builtins
end

if :render in unquote(delegates) do
defdelegate render, to: Solid
end

if :render! in unquote(delegates) do
defdelegate render!, to: Solid
end

if :parse in unquote(delegates) do
defdelegate parse, to: Solid
end

if :parse! in unquote(delegates) do
defdelegate parse!, to: Solid
end
end
end

@moduledoc """
Solid is an implementation in Elixir of the [Liquid](https://shopify.github.io/liquid/) template language with
strict parsing.
Expand Down
202 changes: 127 additions & 75 deletions lib/solid/matcher.ex
Original file line number Diff line number Diff line change
Expand Up @@ -4,95 +4,147 @@ defprotocol Solid.Matcher do
def match(_, _)
end

defimpl Solid.Matcher, for: Any do
def match(data, []), do: {:ok, data}

def match(_, _), do: {:error, :not_found}
end
defmodule Solid.Matcher.Builtins do
@doc """
Solid comes with built-in matchers for the Atom, Map, List, and String/BitString types, as well
as a fallback matcher for the Any type.

defimpl Solid.Matcher, for: List do
def match(data, []), do: {:ok, data}
The using macro supports options to selectively include (`:only`) and exclude (`:except`)
individual matchers, should you wish to replace all or a subset with a custom matcher.

def match(data, ["first"]), do: {:ok, Enum.at(data, 0)}
def match(data, ["last"]), do: {:ok, Enum.at(data, -1)}
def match(data, ["size"]), do: {:ok, Enum.count(data)}
The full list of available matchers is `:any`, `:atom`, `:list`, `:map`, `:string`, `:tuple`

def match(data, [key | keys]) when is_integer(key) do
case Enum.fetch(data, key) do
{:ok, value} -> @protocol.match(value, keys)
_ -> {:error, :not_found}
end
end
Examples:

def match(_data, _) do
{:error, :not_found}
end
end
# include all built-in matchers
use Solid.Matcher.Builtins

defimpl Solid.Matcher, for: Map do
def match(data, []) do
{:ok, data}
end
# selectively include only a subset
use Solid.Matcher.Builtins, only: [:any, :list]

# Maps are not ordered so these are here just for consistency with the Liquid implementation
# as we must return something

def match(data, [key | keys]) do
case Map.fetch(data, key) do
{:ok, value} ->
@protocol.match(value, keys)
# selectively exclude a subset
use Solid.Matcher.Builtins, except: [:map, :atom]
"""

_ ->
# Check if the key is a special case
case key do
"first" -> @protocol.match(Enum.at(data, 0), keys)
"size" -> @protocol.match(map_size(data), keys)
_ -> {:error, :not_found}
@all_matchers [:any, :atom, :string, :list, :map, :tuple]

@type matcher :: :any | :atom | :list | :map | :string | :tuple
@type option :: {:only, list(matcher())} | {:except, list(matcher())}
@type options :: list(option())
@spec __using__(options()) :: Macro.t()
defmacro __using__(opts) do
excluded = Keyword.get(opts, :except, [])

included =
opts
|> Keyword.get(:only, @all_matchers)
|> Enum.reject(fn m -> m in excluded end)

quote do
if :list in unquote(included) do
defimpl Solid.Matcher, for: List do
def match(data, []), do: {:ok, data}

def match(data, ["first"]), do: {:ok, Enum.at(data, 0)}
def match(data, ["last"]), do: {:ok, Enum.at(data, -1)}
def match(data, ["size"]), do: {:ok, Enum.count(data)}

def match(data, [key | keys]) when is_integer(key) do
case Enum.fetch(data, key) do
{:ok, value} -> @protocol.match(value, keys)
_ -> {:error, :not_found}
end
end

def match(_data, _) do
{:error, :not_found}
end
end
end
end
end

defimpl Solid.Matcher, for: BitString do
def match(current, []), do: {:ok, current}

def match(data, ["size"]) do
{:ok, String.length(data)}
end
end

if :map in unquote(included) do
defimpl Solid.Matcher, for: Map do
def match(data, []) do
{:ok, data}
end

# Maps are not ordered so these are here just for consistency with the Liquid implementation
# as we must return something

def match(data, [key | keys]) do
case Map.fetch(data, key) do
{:ok, value} ->
@protocol.match(value, keys)

_ ->
# Check if the key is a special case
case key do
"first" -> @protocol.match(Enum.at(data, 0), keys)
"size" -> @protocol.match(map_size(data), keys)
_ -> {:error, :not_found}
end
end
end
end
end

def match(_data, [i | _]) when is_integer(i) do
{:error, :not_found}
end
if :string in unquote(included) do
defimpl Solid.Matcher, for: [BitString, String] do
def match(current, []), do: {:ok, current}

def match(_data, [i | _]) when is_binary(i) do
{:error, :not_found}
end
end

defimpl Solid.Matcher, for: Atom do
def match(current, []) when is_nil(current), do: {:ok, nil}
def match(data, []), do: {:ok, data}
def match(nil, _), do: {:error, :not_found}
def match(data, ["size"]) do
{:ok, String.length(data)}
end

@doc """
Matches all remaining cases
"""
def match(_current, [key]) when is_binary(key), do: {:error, :not_found}
end
def match(_data, [i | _]) when is_integer(i) do
{:error, :not_found}
end

defimpl Solid.Matcher, for: Tuple do
def match(data, []), do: {:ok, data}
def match(_data, [i | _]) when is_binary(i) do
{:error, :not_found}
end
end
end

if :tuple in unquote(included) do
defimpl Solid.Matcher, for: Tuple do
def match(data, []), do: {:ok, data}

def match(data, ["size"]) do
{:ok, tuple_size(data)}
end

def match(data, [key | keys]) when is_integer(key) do
try do
elem(data, key)
|> @protocol.match(keys)
rescue
ArgumentError -> {:error, :not_found}
end
end
end
end

if :atom in unquote(included) do
defimpl Solid.Matcher, for: Atom do
def match(current, []) when is_nil(current), do: {:ok, nil}
def match(data, []), do: {:ok, data}
def match(nil, _), do: {:error, :not_found}

@doc """
Matches all remaining cases
"""
def match(_current, [key]) when is_binary(key), do: {:error, :not_found}
end
end

def match(data, ["size"]) do
{:ok, tuple_size(data)}
end
if :any in unquote(included) do
defimpl Solid.Matcher, for: Any do
def match(data, []), do: {:ok, data}

def match(data, [key | keys]) when is_integer(key) do
try do
elem(data, key)
|> @protocol.match(keys)
rescue
ArgumentError -> {:error, :not_found}
def match(_, _), do: {:error, :not_found}
end
end
end
end
end
Loading
Loading