defmodule FrancisTemplate do
@moduledoc """
File-based templates with layouts and pluggable engines for
[Francis](https://hex.pm/packages/francis).
Templates live under a configurable root directory (default: `priv/templates`)
and are rendered to a string. A route handler can return that string directly
or, more commonly, send it with `render/4`, which sets the HTML headers for you.
## Usage
defmodule MyApp do
use Francis
use FrancisTemplate
# priv/templates/index.html.eex => <h1>Hello <%= @name %></h1>
get("/", fn conn -> render(conn, "index.html.eex", name: "World") end)
end
`use FrancisTemplate` imports `render/2,3,4` and `render_to_string/1,2,3` so
they read like the other Francis response helpers (`html/2`, `json/2`, ...).
You can also call `FrancisTemplate.render/4` fully qualified without the
`use`.
## Engines
The engine is chosen from the template's file extension. The built-in engine,
`FrancisTemplate.EEx`, handles `.eex` and is registered out of the box. Add or
override engines with the `:engines` config — for example a Liquid engine
backed by [Solid](https://hex.pm/packages/solid), handy if you also write
Shopify themes. See `FrancisTemplate.Engine`.
## Layouts
A layout is an ordinary template that wraps the rendered content, which is
exposed to it as the `inner_content` assign:
# priv/templates/layout.html.eex
# <html><body><%= @inner_content %></body></html>
A `layout.html.eex` at the template root is applied to every render
automatically — no configuration required. Point `:layout` at a different file
to change it, pass `:layout` per render to override, or pass `layout: false`
to render without a layout:
render(conn, "index.html.eex", [name: "World"], layout: false)
Assigns flow to both the content template and the layout, so a layout can use
`<%= @title %>` alongside `<%= @inner_content %>`. The layout's engine is
resolved from its own extension, so it need not match the content's engine.
Because the default EEx engine does not auto-escape, the already-rendered
`inner_content` is spliced in verbatim.
## Escaping
The default `FrancisTemplate.EEx` engine does **not** auto-escape — escaping is
the template's concern, consistent with `Francis.ResponseHandlers.html/2`.
Escape untrusted assigns with `Francis.HTML.escape/1` inside the template:
<p>Bio: <%= Francis.HTML.escape(@bio) %></p>
If you want auto-escaping everywhere, register an engine that wraps an
escaping EEx engine (e.g. `Phoenix.HTML.Engine`); that keeps the dependency in
your app rather than in this package.
## Configuration
* `:root` — directory templates are read from. Defaults to
`"priv/templates"`, resolved relative to the current working directory. In
a release, set this to an absolute path (e.g.
`Application.app_dir(:my_app, "priv/templates")`) since `priv` — not the
directory's relative location — is what ships.
* `:engines` — a map of file extension (without the dot) to an engine module
implementing `FrancisTemplate.Engine`. Merged over the built-in
`%{"eex" => FrancisTemplate.EEx}`.
* `:layout` — the layout template (relative to `:root`) that wraps every
render unless overridden per call. Defaults to `"layout.html.eex"` when
that file exists, otherwise no layout.
config :francis_template,
engines: %{"liquid" => MyApp.LiquidEngine},
layout: "base.html.eex"
"""
@default_engines %{"eex" => FrancisTemplate.EEx}
@default_root "priv/templates"
@default_layout "layout.html.eex"
@doc """
Imports `render/2,3,4` and `render_to_string/1,2,3` into the calling module so
they can be called bare alongside the other Francis response helpers.
"""
defmacro __using__(_opts) do
quote do
import FrancisTemplate,
only: [
render: 2,
render: 3,
render: 4,
render_to_string: 1,
render_to_string: 2,
render_to_string: 3
]
end
end
@doc """
Renders `template` and sends it as an HTML response with a 200 status code.
`template` is a path relative to the configured template root and the engine
is chosen from its file extension. `assigns` and `opts` are passed through to
`render_to_string/3`.
## Examples
get("/", fn conn -> render(conn, "index.html.eex", name: "World") end)
"""
@spec render(Plug.Conn.t(), String.t(), map() | keyword(), keyword()) :: Plug.Conn.t()
def render(conn, template, assigns \\ %{}, opts \\ []) do
Francis.ResponseHandlers.html(conn, render_to_string(template, assigns, opts))
end
@doc """
Renders `template` (a path relative to the template root) with `assigns` and
returns the result as a binary.
The engine is chosen from the template's file extension.
## Options
* `:layout` — a layout template to wrap the result. Overrides the `:layout`
config. Pass `false` to skip a configured layout. See the "Layouts"
section in `FrancisTemplate`.
"""
@spec render_to_string(String.t(), map() | keyword(), keyword()) :: binary()
def render_to_string(template, assigns \\ %{}, opts \\ []) do
inner = render_to_binary(template, assigns)
case layout(opts) do
nil -> inner
layout -> render_to_binary(layout, put_inner(assigns, inner))
end
end
defp render_to_binary(template, assigns) do
path = full_path(template)
engine = engine_for(path)
path
|> engine.render(assigns)
|> IO.iodata_to_binary()
end
defp layout(opts) do
case Keyword.get(opts, :layout, :default) do
:default -> default_layout()
false -> nil
layout -> layout
end
end
# A layout configured via `:layout` (or passed per render) is used as given.
# Absent that, a `layout.html.eex` at the template root is used by convention
# when it exists — so layouts work with no configuration.
defp default_layout do
case Application.get_env(:francis_template, :layout) do
nil -> if File.exists?(full_path(@default_layout)), do: @default_layout
layout -> layout
end
end
defp put_inner(assigns, inner) when is_map(assigns), do: Map.put(assigns, :inner_content, inner)
defp put_inner(assigns, inner) when is_list(assigns),
do: Keyword.put(assigns, :inner_content, inner)
defp full_path(template) do
:francis_template
|> Application.get_env(:root, @default_root)
|> Path.join(template)
end
defp engine_for(path) do
ext = path |> Path.extname() |> String.trim_leading(".")
engines = Map.merge(@default_engines, Application.get_env(:francis_template, :engines, %{}))
case Map.fetch(engines, ext) do
{:ok, engine} ->
engine
:error ->
raise ArgumentError,
"no template engine registered for extension #{inspect(ext)} " <>
"(template: #{inspect(path)})"
end
end
end