Skip to content

Commit 4a6c8be

Browse files
committed
Initial commit
0 parents  commit 4a6c8be

12 files changed

Lines changed: 415 additions & 0 deletions

File tree

.gitignore

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
/_build
2+
/cover
3+
/deps
4+
erl_crash.dump
5+
*.ez
6+
/src/solid_parser.erl

LICENSE

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
Copyright (c) 2016 Eduardo Gurgel Pinho
2+
3+
Permission is hereby granted, free of charge, to any person obtaining
4+
a copy of this software and associated documentation files (the
5+
"Software"), to deal in the Software without restriction, including
6+
without limitation the rights to use, copy, modify, merge, publish,
7+
distribute, sublicense, and/or sell copies of the Software, and to
8+
permit persons to whom the Software is furnished to do so, subject to
9+
the following conditions:
10+
11+
The above copyright notice and this permission notice shall be
12+
included in all copies or substantial portions of the Software.
13+
14+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

README.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# Solid
2+
3+
Solid is an implementation in Elixir of the template engine Liquid. It uses [neotoma](https://github.com/seancribbs/neotoma) to generate the parser.
4+
5+
## Basic Usage
6+
7+
```elixir
8+
iex> template = "My name is {{ user.name }}"
9+
iex> Solid.parse(template) |> Solid.render(%{ "user" => %{ "name" => "José" } }) |> to_string
10+
"My name is José"
11+
```
12+
13+
## Installation
14+
15+
The package can be installed as:
16+
17+
1. Add solid to your list of dependencies in `mix.exs`:
18+
19+
def deps do
20+
[{:solid, "~> 0.0.1"}]
21+
end
22+
23+
2. Ensure solid is started before your application:
24+
25+
def application do
26+
[applications: [:solid]]
27+
end
28+
29+
## TODO
30+
31+
* [ ] Integration tests using Liquid gem to build fixtures;
32+
* [ ] All the standard filters
33+
* [ ] Tags (if, case, unless, etc)
34+
* [ ] Boolean operators
35+
* [ ] Whitespace control

lib/solid.ex

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
defmodule Solid do
2+
@moduledoc """
3+
Main module to interact with Solid
4+
5+
iex> Solid.parse("{{ variable }}") |> Solid.render(%{ "variable" => "value" }) |> to_string
6+
"value"
7+
"""
8+
alias Solid.{Argument, Filter}
9+
10+
@doc """
11+
It generates the compiled template
12+
"""
13+
@spec parse(String.t) :: any
14+
def parse(text) do
15+
:solid_parser.parse(text)
16+
end
17+
18+
@doc """
19+
It renders the compiled template using a `hash` with variables
20+
"""
21+
@spec render(any, Map.t) :: iolist
22+
def render({:text, text} = template, hash \\ %{}) do
23+
case text do
24+
[{:string, string} | tail] -> [string | render_liquid(hd(tail), hash)]
25+
end
26+
end
27+
28+
def render_liquid([], _hash), do: []
29+
def render_liquid(liquid, hash) when is_list(liquid) do
30+
argument = get_in(liquid, [:liquid, :argument])
31+
value = Argument.get(argument, hash)
32+
33+
filters = get_in(liquid, [:liquid, :filters])
34+
value = value |> apply_filters(filters, hash)
35+
36+
[to_string(value) | render(liquid[:text], hash)]
37+
end
38+
39+
defp apply_filters(input, nil, _), do: input
40+
defp apply_filters(input, [], _), do: input
41+
defp apply_filters(input, [{filter, args} | filters], hash) do
42+
values = for arg <- args, do: Argument.get(arg, hash)
43+
apply(Filter, String.to_existing_atom(filter), [input | values]) |> apply_filters(filters, hash)
44+
end
45+
end

lib/solid/argument.ex

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
defmodule Solid.Argument do
2+
def get({:field, key}, hash) do
3+
key = key |> String.split(".")
4+
get_in(hash, key)
5+
end
6+
def get({:value, val}, _hash), do: val
7+
end

lib/solid/filter.ex

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
defmodule Solid.Filter do
2+
@moduledoc """
3+
Standard filters
4+
"""
5+
6+
def default(nil, value), do: value
7+
def default(input, _), do: input
8+
9+
def upcase(input), do: input |> to_string |> String.upcase
10+
11+
def downcase(input), do: input |> to_string |> String.downcase
12+
13+
def replace(input, string, replacement \\ "") do
14+
input |> to_string |> String.replace(string, replacement)
15+
end
16+
end

mix.exs

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
defmodule Solid.Mixfile do
2+
use Mix.Project
3+
4+
@description """
5+
Liquid Template engine
6+
"""
7+
8+
@compile_peg_task "tasks/compile.peg.exs"
9+
Code.eval_file @compile_peg_task
10+
11+
def project do
12+
[app: :solid,
13+
version: "0.0.1",
14+
elixir: "~> 1.3",
15+
build_embedded: Mix.env == :prod,
16+
start_permanent: Mix.env == :prod,
17+
compilers: [:peg, :erlang, :elixir, :app],
18+
name: "solid",
19+
description: @description,
20+
package: package,
21+
deps: deps]
22+
end
23+
24+
def application do
25+
[applications: []]
26+
end
27+
28+
defp deps do
29+
[{:neotoma, "~> 1.7.3"},
30+
{:earmark, "~> 1.0", only: :dev},
31+
{:ex_doc, "~> 0.13", only: :dev}]
32+
end
33+
34+
defp package do
35+
[ maintainers: ["Eduardo Gurgel Pinho"],
36+
licenses: ["MIT"],
37+
links: %{"Github" => "https://github.com/edgurgel/solid"} ]
38+
end
39+
end

mix.lock

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
%{"earmark": {:hex, :earmark, "1.0.1", "2c2cd903bfdc3de3f189bd9a8d4569a075b88a8981ded9a0d95672f6e2b63141", [:mix], []},
2+
"ex_doc": {:hex, :ex_doc, "0.13.0", "aa2f8fe4c6136a2f7cfc0a7e06805f82530e91df00e2bff4b4362002b43ada65", [:mix], [{:earmark, "~> 1.0", [hex: :earmark, optional: false]}]},
3+
"neotoma": {:hex, :neotoma, "1.7.3", "d8bd5404b73273989946e4f4f6d529e5c2088f5fa1ca790b4dbe81f4be408e61", [:rebar], []}}

src/solid_parser.peg

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
text <- string:blob (liquid:liquid text:text)? `{text, Node}`;
2+
blob <- ((!open_block .)*) ~;
3+
liquid <- open_block:open_block space (argument:argument filters:filters / argument:argument)? space close_block:close_block `
4+
case Node of
5+
[_, _, Block, _, _] -> lists:flatten([Block])
6+
end
7+
`;
8+
filters <- (space "|" space filter (":" space arguments)? )+`
9+
[case N of
10+
[_, _, _, Filter, [_, _, Args]] -> {Filter, Args};
11+
[_, _, _, Filter, _] -> {Filter, []}
12+
end || N <- Node]
13+
`;
14+
arguments <- argument (space "," space argument)* `
15+
case Node of
16+
[Arg1, Args] -> [Arg1 | [Arg || [_, _, _, Arg] <- Args]]
17+
end
18+
`;
19+
argument <- value:value / field:field ~;
20+
value <- space (string / number / true / false / null) space `lists:nth(2, Node)`;
21+
22+
%% Terminals
23+
24+
open_block <- "{{" ~;
25+
close_block <- "}}" ~;
26+
field <- [0-9a-zA-Z\.]* `iolist_to_binary(Node)`;
27+
filter <- [a-zA-Z]* `iolist_to_binary(Node)`;
28+
space <- [ \t\n\s\r]* `{space, iolist_to_binary(Node)}`;
29+
30+
string <- '"' chars:(!'"' ("\\\\" / '\\"' / .))* '"' `
31+
iolist_to_binary(proplists:get_value(chars, Node))
32+
`;
33+
number <- int frac? exp?
34+
`
35+
case Node of
36+
[Int, [], []] -> list_to_integer(binary_to_list(iolist_to_binary(Int)));
37+
[Int, Frac, []] -> list_to_float(binary_to_list(iolist_to_binary([Int, Frac])));
38+
[Int, [], Exp] -> list_to_float(binary_to_list(iolist_to_binary([Int, ".0", Exp])));
39+
_ -> list_to_float(binary_to_list(iolist_to_binary(Node)))
40+
end
41+
`;
42+
int <- '-'? (non_zero_digit digit+) / digit ~;
43+
frac <- '.' digit+ ~;
44+
exp <- e digit+ ~;
45+
e <- [eE] ('+' / '-')? ~;
46+
non_zero_digit <- [1-9] ~;
47+
digit <- [0-9] ~;
48+
true <- 'true' `true`;
49+
false <- 'false' `false`;
50+
null <- 'nil' `nil`;

tasks/compile.peg.exs

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
# Copyright (c) 2014 Paul Schoenfelder
2+
# From the conform library: https://github.com/bitwalker/conform
3+
defmodule Mix.Tasks.Compile.Peg do
4+
@moduledoc """
5+
Compiles Erlang Parsing Expression Grammars (PEGs).
6+
"""
7+
@shortdoc "Compiles Erlang Parsing Expression Grammars (PEGs)."
8+
9+
use Mix.Task
10+
11+
@recursive true
12+
@manifest ".compile.peg"
13+
14+
def run(_) do
15+
manifest = load_manifest!()
16+
get_sources()
17+
|> Enum.filter(&(compile?(&1, manifest)))
18+
|> Enum.map(&compile_peg/1)
19+
|> Enum.reduce([], &build_manifest/2)
20+
|> write_manifest!
21+
end
22+
23+
def clean do
24+
get_sources() |> Enum.map(&do_clean/1)
25+
end
26+
27+
defp do_clean(source_path) do
28+
erl = erl_source_path(source_path)
29+
case File.exists?(erl) do
30+
true -> File.rm!(erl)
31+
false -> :ok
32+
end
33+
end
34+
35+
defp get_sources,
36+
do: Mix.Project.config[:erlc_paths] |> Mix.Utils.extract_files([:peg])
37+
38+
defp compile?(source_path, manifest) do
39+
erl = source_path |> erl_source_path |> Path.expand
40+
peg = source_path |> Path.expand
41+
# If the compiled file doesn't exist, then compile
42+
case File.exists?(erl) do
43+
true ->
44+
# If the manifest doesn't contain an entry for the peg file, then compile
45+
case Map.get(manifest, source_path) do
46+
nil -> true
47+
last_compilation ->
48+
# Otherwise, compare the modified time of the peg file against the
49+
# last compilation time (in seconds) of that file, if modifications
50+
# have been made, then compile
51+
{:ok, %File.Stat{mtime: mtime}} = File.stat(peg)
52+
:calendar.datetime_to_gregorian_seconds(mtime) > last_compilation
53+
end
54+
false -> true
55+
end
56+
end
57+
58+
defp compile_peg(source_path) do
59+
peg = Path.expand(source_path)
60+
relative_path = Path.relative_to_cwd(source_path)
61+
info "Compiling #{relative_path}"
62+
case :neotoma.file('#{peg}') do
63+
:ok -> :ok
64+
{:error, reason} ->
65+
error "Failed to compile #{relative_path}: #{reason}"
66+
exit(:normal)
67+
end
68+
case File.stat(peg) do
69+
{:ok, %File.Stat{mtime: last_compilation}} ->
70+
{source_path, last_compilation |> :calendar.datetime_to_gregorian_seconds}
71+
{:error, :enoent} ->
72+
error "Cannot stat #{relative_path}!"
73+
exit(:normal)
74+
end
75+
end
76+
77+
defp build_manifest({source_path, compilation_time}, manifest) do
78+
[<<source_path :: binary, ?\t, ?\t, "#{compilation_time}" :: binary>> | manifest]
79+
end
80+
81+
defp write_manifest!([]), do: :ok
82+
defp write_manifest!(manifest) when is_list(manifest) do
83+
serialized = manifest |> Enum.join(<<?\n>>)
84+
output_path = manifest_path()
85+
case File.write!(output_path, serialized) do
86+
:ok -> info "PEG manifest updated"
87+
{:error, reason} -> error "Unable to save PEG manifest: #{reason}"
88+
end
89+
end
90+
91+
defp erl_source_path(source_path), do: String.replace(source_path, ".peg", ".erl")
92+
defp manifest_path, do: Mix.Project.app_path |> Path.join(@manifest) |> Path.expand
93+
94+
defp load_manifest! do
95+
manifest = manifest_path()
96+
case File.exists?(manifest_path()) do
97+
true ->
98+
# Create resource from manifest
99+
res = Stream.resource(
100+
fn -> File.open!(manifest) end,
101+
fn file ->
102+
case IO.read(file, :line) do
103+
data when is_binary(data) -> {[data], file}
104+
_ -> {:halt, file}
105+
end
106+
end,
107+
fn file -> File.close(file) end
108+
)
109+
# For each line in the manifest, split on \t, where the
110+
# first part is the source file path, and the second part
111+
# is the time at which that file was last compiled
112+
res
113+
|> Stream.map(fn line ->
114+
case line |> String.split(<<?\t, ?\t>>, [parts: 2, trim: true]) do
115+
[path, mtime] ->
116+
{mtime, _} = Integer.parse(mtime)
117+
{path, mtime}
118+
_ ->
119+
nil
120+
end
121+
end)
122+
|> Stream.filter(fn meta -> meta != nil end)
123+
|> Enum.into(%{})
124+
false ->
125+
%{}
126+
end
127+
end
128+
129+
defp info(message), do: print(message)
130+
defp error(message), do: print(message, IO.ANSI.red)
131+
defp print(message, color \\ nil) do
132+
has_colors? = IO.ANSI.enabled?
133+
cond do
134+
color == nil -> message
135+
has_colors? -> color <> message <> IO.ANSI.reset
136+
true -> message
137+
end |> IO.puts
138+
end
139+
140+
end

0 commit comments

Comments
 (0)