FrancisTemplate

File-based templates with layouts and pluggable engines for the Francis micro-framework.

Francis ships response helpers like html/2, json/2 and text/2, and the companion francis_htmx renders EEx inline with the ~E sigil. francis_template fills the other gap: rendering templates from separate files on disk, wrapping them in layouts, and choosing the renderer by file extension so you can swap in other engines (e.g. Liquid via Solid).

It depends only on francis — no phoenix_html, no heavy view layer.

Installation

def deps do
  [
    {:francis, "~> 0.1"},
    {:francis_template, "~> 0.1"}
  ]
end

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 (sends a 200 HTML response) and render_to_string/1,2,3 (returns a binary), so they read like the other Francis helpers. You can also call FrancisTemplate.render/4 fully qualified.

Templates are read from priv/templates by default; the engine is picked from the file extension (.eex out of the box).

Layouts

A layout is an ordinary template that wraps the rendered content, exposed to it as the @inner_content assign. A layout.html.eex at the template root is applied to every render automatically — no configuration needed:

<%# priv/templates/layout.html.eex %>
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>My Site</title>
    <link rel="stylesheet" href="/app.css" />
    <%# analytics / other <head> tags go here %>
  </head>
  <body>
    <%= @inner_content %>
  </body>
</html>

Override per render, or skip a configured layout:

render(conn, "index.html.eex", [name: "World"], layout: "admin.html.eex")
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 %>.

Serving plain static pages

Even with no <%= %> tags, an .html.eex file is just static HTML. Drop your pages in priv/templates, share one layout.html.eex for the <head>, and map routes to them:

get("/",        fn conn -> render(conn, "index.html.eex") end)
get("/about",   fn conn -> render(conn, "about.html.eex") end)
get("/contact", fn conn -> render(conn, "contact.html.eex") end)

When you later add dynamic data (e.g. presence counts), pass assigns — no restructuring required.

Custom engines (Liquid / Solid, ...)

Implement FrancisTemplate.Engine and register it for an extension. This is handy if you also write Shopify themes and want to reuse Liquid:

defmodule MyApp.LiquidEngine do
  @behaviour FrancisTemplate.Engine

  @impl true
  def render(path, assigns) do
    path
    |> File.read!()
    |> Solid.parse!()
    |> Solid.render!(stringify_keys(assigns))
    |> to_string()
  end

  defp stringify_keys(assigns),
    do: Map.new(assigns, fn {k, v} -> {to_string(k), v} end)
end
# config/config.exs
config :francis_template, engines: %{"liquid" => MyApp.LiquidEngine}

Now render(conn, "page.liquid", products: products) renders with Solid, while .eex files keep using the built-in engine.

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 (shipped with Francis, zero extra deps) 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

config :francis_template,
  # directory templates are read from (default "priv/templates")
  root: "priv/templates",
  # extra/override engines, merged over %{"eex" => FrancisTemplate.EEx}
  engines: %{"liquid" => MyApp.LiquidEngine},
  # layout wrapping every render; defaults to "layout.html.eex" if it exists
  layout: "base.html.eex"

In a release, set :root to an absolute path (Application.app_dir(:my_app, "priv/templates")) since priv — not the directory's relative location — is what ships.

License

MIT