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,
including Liquid via Solid and Markdown via
MDEx.
It has no Phoenix dependency. Solid and MDEx are optional, so EEx-only applications do not need to install them.
def deps do
[
{:francis, "~> 0.3.0"},
{:francis_template, "~> 0.2"}
]
endFor Liquid templates, add Solid too:
{:solid, "~> 1.0"}For Markdown templates, add MDEx too (MDEx requires Elixir 1.15 or later):
{:mdex, "~> 0.13.5"}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)
enduse 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, .liquid, or .md out of the box).
Use standard Liquid objects, tags, and filters directly in .liquid files.
Elixir atom keys are recursively converted to Liquid's string keys.
get("/catalog", fn conn ->
render(conn, "catalog.liquid",
shop: %{name: "Tiny Store"},
products: [
%{title: "Mug", available: true},
%{title: "Poster", available: false}
]
)
end){% comment %} priv/templates/catalog.liquid {% endcomment %}
<h1>{{ shop.name | escape }}</h1>
{% assign available_products = products | where: "available", true %}
{% for product in available_products %}
{% render "product_card", product: product %}
{% endfor %}{% comment %} priv/templates/_product_card.liquid {% endcomment %}
<article>{{ product.title | escape }}</article>The render tag resolves partials from the configured template root using
Liquid's _name.liquid convention. Layouts can be Liquid too:
<!doctype html>
<title>{{ title | escape }}</title>
<main>{{ inner_content }}</main>Run the complete example included in this package with:
mix run examples/liquid.exsFor a real Francis route you can open in a browser—and later deploy—run the storefront example:
cd examples/storefront
mix deps.get
mix run --no-haltPass strict mode, custom filters, custom tags, matchers, caching, or a custom partial file system through the Liquid engine config:
config :francis_template,
liquid: [
parse_options: [tags: MyApp.LiquidTags.all()],
render_options: [
custom_filters: MyApp.LiquidFilters,
strict_variables: true,
strict_filters: true
]
]This covers the open Liquid language implemented by Solid. Shopify's hosted
theme runtime is a larger environment: it adds store objects such as product
and shop, plus Shopify-only tags and filters. Pass store data as assigns and
implement the Shopify-specific surface you need with Solid custom tags, filters,
and matchers; francis_template does not pretend to be a Shopify storefront.
Use .md files for articles, documentation, changelogs, and other authored
content. Markdown is converted to HTML by MDEx and can use a layout written in
any registered engine:
get("/journal", fn conn ->
render(conn, "journal.md", [title: "Field notes"],
layout: "layout.html.eex"
)
end)# Field notes
Francis renders **Markdown** directly from a file.Markdown itself does not interpolate assigns. Assigns remain available to the layout and are forwarded to MDEx for plugins and pipelines.
Enable CommonMark extensions by passing options to MDEx:
config :francis_template,
markdown: [
extension: [table: true, strikethrough: true, tasklist: true]
]MDEx omits raw HTML by default. Keep that default for untrusted content. Trusted
templates can opt in with render: [unsafe: true]; untrusted raw HTML should use
MDEx sanitization instead.
The storefront example includes a Markdown journal route wrapped in its Liquid layout:
cd examples/storefront
mix deps.get
mix run --no-halt
# open http://localhost:4100/journalA 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 %>.
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.
Implement FrancisTemplate.Engine and register it for another extension:
defmodule MyApp.UpcaseEngine do
@behaviour FrancisTemplate.Engine
@impl true
def render(path, _assigns), do: path |> File.read!() |> String.upcase()
end# config/config.exs
config :francis_template, engines: %{"up" => MyApp.UpcaseEngine}Built-in engines can be overridden through the same config.
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.
config :francis_template,
# directory templates are read from (default "priv/templates")
root: "priv/templates",
# extra/override engines, merged over the built-in engines
engines: %{"up" => MyApp.UpcaseEngine},
# options forwarded to Solid by FrancisTemplate.Liquid
liquid: [render_options: [strict_variables: true]],
# options forwarded to MDEx by FrancisTemplate.Markdown
markdown: [extension: [table: true]],
# 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.
MIT