Packages
A custom EEx engine that helps you get both readable templates and properly formatted output.
Current section
Files
Jump to
Current section
Files
lib/smart_indentation_engine.ex
defmodule SmartIndentationEngine do
@moduledoc """
A custom EEx engine that helps maintain proper indentation.
This engine extends the EEx.SmartEngine functionality and adds support for the
pipe marker (`<%|`) which can be used like this:
<%| if cond do %>
Some text
<% end %>
This marker will reindent the output of the block so that it's aligned with
indentation of the `if` line.
"""
@behaviour EEx.Engine
# Delegate basic functionality to EEx.Engine
@impl true
defdelegate init(opts), to: EEx.Engine
@impl true
defdelegate handle_body(state), to: EEx.Engine
@impl true
defdelegate handle_begin(state), to: EEx.Engine
@impl true
defdelegate handle_end(state), to: EEx.Engine
@impl true
defdelegate handle_text(state, meta, text), to: EEx.Engine
@impl true
def handle_expr(%{vars_count: vars_count, binary: binary} = state, "|", expr) do
[current_indentation, block_indentation] = indentation_state(state, expr)
# Don't output empty newline for `<%| ... do %>` lines
binary = trim_empty_block_line(binary, expr)
# Create new variables (i.e. arg0, arg1, ...) (see default EEx.Engine impl)
var = Macro.var(:"arg#{vars_count}", __MODULE__)
ast =
expr
# Process assigns like @foo before handling the expression
|> Macro.prewalk(&EEx.Engine.handle_assign/1)
|> then("e(do: String.Chars.to_string(unquote(&1))))
|> maybe_replace_block_indentation(current_indentation, block_indentation)
|> maybe_trim_first_line(binary)
|> trim_last_block_newline()
|> then("e(do: unquote(var) = unquote(&1)))
# Create an AST for a binary segment referencing arg0 that will be used to
# form the template output (see default EEx.Engine impl)
segment = quote(do: unquote(var) :: binary)
%{
state
| dynamic: [ast | state.dynamic],
binary: [segment | binary],
vars_count: vars_count + 1
}
end
def handle_expr(state, marker, expr) do
[indentation, _] = indentation_state(state, expr)
expr =
expr
# Process assigns like @foo before handling the expression
|> Macro.prewalk(&EEx.Engine.handle_assign/1)
# Reindent include calls
|> Macro.postwalk(&handle_include(&1, indentation))
EEx.Engine.handle_expr(state, marker, expr)
end
# Reindent include calls according to the indentation state
defp handle_include({:include, _meta, _args} = include_call, indentation) do
reindent_include_call(include_call, indentation)
end
defp handle_include(ast, _), do: ast
# Extract current indentation and sub block indentation if any
defp indentation_state(%{binary: binary}, expr) do
# Extract indentation from the content before the `<%|` tag
indentation =
with binary when is_list(binary) <- binary,
string when is_binary(string) <- List.first(binary) do
trailing_whitespace(string)
else
_ -> ""
end
block_indentation =
case do_block(expr) do
false -> indentation
block -> block_indentation(block)
end
[indentation, block_indentation]
end
# NOTE: find if we still have a case resulting in this
# defp trailing_whitespace(ast) when is_tuple(ast), do: ""
defp trailing_whitespace(string) do
[_, whitespace] = Regex.run(~r/([ \t]*)\z/, string)
whitespace
end
# Extracts the indentation from the binary concatenation part of the block
# {:__block__, [],
# [
# {:=, [], [...]},
# ...
# {:<<>>, [],
# [
# "\n ", # <-- this is the indentation we want to extract
# {:"::", [], ...},
# "\n "
# ]}
# ]}
defp block_indentation({:__block__, _meta, args}) when is_list(args) do
with last_arg when not is_nil(last_arg) <- List.last(args),
{:<<>>, _meta, parts} <- last_arg,
first_part when is_binary(first_part) <- List.first(parts),
[_, indentation] <- Regex.run(~r/^\n+([ \t]*)/, first_part) do
indentation
else
_ -> ""
end
end
defp do_block({_op, _meta, args}) when is_list(args) do
with last_arg when is_list(last_arg) <- List.last(args),
true <- Keyword.keyword?(last_arg),
{:ok, do_expr} <- Keyword.fetch(last_arg, :do),
[block | _] <- Macro.path(do_expr, &match?({:__block__, _, _}, &1)) do
block
else
_ -> false
end
end
defp do_block(_), do: false
defp reindent_include_call({:include, meta, _args} = include_call, indentation) do
quote(line: meta[:line] || 0) do
unquote(include_call)
|> String.split("\n")
|> Enum.with_index()
|> Enum.map_join(
"\n",
fn
{"", _} -> ""
# Skip the first line
{line, 0} -> line
{line, _} -> "#{unquote(indentation)}#{line}"
end
)
end
end
# Remove empty newline from binary if the expression is a block definition
defp trim_empty_block_line([head | tail] = binary, expr) do
if do_block(expr) do
[String.replace(head, ~r/\n[ \t]*\z/, "") | tail]
else
binary
end
end
defp trim_empty_block_line(binary, _), do: binary
# Remove the last new line from the binary concatenation (the one before `<% end %>`)
defp trim_last_block_newline(ast) do
Macro.prewalk(ast, fn
{:<<>>, meta, parts} -> {:<<>>, meta, maybe_trim_last_part(parts)}
other -> other
end)
end
defp maybe_trim_last_part(parts) do
last_part = List.last(parts)
empty_last_line = ~r/\n[ \t]*\z/
with true <- is_binary(last_part),
trimmed_last <- String.replace(last_part, empty_last_line, ""),
true <- trimmed_last != last_part do
List.replace_at(parts, length(parts) - 1, trimmed_last)
else
_ -> parts
end
end
# Replace block indentation with current indentation if they differ
defp maybe_replace_block_indentation(ast, current, current), do: ast
defp maybe_replace_block_indentation(ast, current, block), do: replace_indentation(ast, block, current)
defp replace_indentation(ast, indentation, new_indentation) do
quote do
String.replace(
unquote(ast),
~r/\n#{unquote(indentation)}/,
"\n#{unquote(new_indentation)}"
)
end
end
# Remove the first `\n` from the output if the binary is empty (i.e. the first
# line of the template)
defp maybe_trim_first_line(ast, []), do: trim_first_line(ast)
defp maybe_trim_first_line(ast, _), do: ast
defp trim_first_line(ast) do
quote do
String.replace(unquote(ast), ~r/^\n/, "")
end
end
end
defmodule SmartIndentationEngine.Template do
@moduledoc """
Provides the `~TT` sigil to compile templates with the
`SmartIndentationEngine`, and the `include/2` macro for rendering partials.
"""
@doc """
Renders a partial defined by the given function name.
## Example
<%= include :partial %>
<%= include :partial, name: true %>
This will call `partial/1` function with the current assigns.
"""
# Note: only support keyword arguments for now, could be nice to add maps?
defmacro include(partial_name, args \\ []) do
quote do
unquote(partial_name)(Keyword.merge(var!(assigns), unquote(args)))
end
end
@doc ~S'''
Compiles a template string using the `SmartIndentationEngine`.
## Example
defmodule MyApp.Template do
import SmartIndentationEngine.Template
def render(assigns) do
~TT"""
<%| case @lang do %>
<% :fr -> %>
<%= include :french %>
<% _ -> %>
<%= include :english %>
<% end %>
"""
end
def french(assigns) do
~TT(Bonjour <%= @name %>)
end
def english(assigns) do
~TT(Hello <%= @name %>)
end
end
MyApp.Template.render(lang: :fr, name: "John")
# => "Bonjour John"
'''
defmacro sigil_TT({:<<>>, meta, [template_string]}, []) do
template_string = String.replace(template_string, ~r/\n[ \t]*\z/, "")
options = [
engine: SmartIndentationEngine,
file: __CALLER__.file,
line: __CALLER__.line + 1,
caller: __CALLER__,
indentation: meta[:indentation] || 0
]
EEx.compile_string(template_string, options)
end
end