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 =>

Hello <%= @name %>

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 # <%= @inner_content %> 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:

Bio: <%= Francis.HTML.escape(@bio) %>

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