Packages
File-based templates with layouts and pluggable engines (EEx, Liquid, Markdown, ...) for the Francis micro-framework
Current section
Files
Jump to
Current section
Files
francis_template
README.md
README.md
# FrancisTemplate
File-based templates with layouts and pluggable engines for the
[Francis](https://hex.pm/packages/francis) micro-framework.
Francis ships response helpers like `html/2`, `json/2` and `text/2`, and the
companion [`francis_htmx`](https://hex.pm/packages/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](https://hex.pm/packages/solid) and Markdown via
[MDEx](https://hex.pm/packages/mdex).
It has no Phoenix dependency. Solid and MDEx are optional, so EEx-only
applications do not need to install them.
## Installation
```elixir
def deps do
[
{:francis, "~> 0.3.0"},
{:francis_template, "~> 0.2"}
]
end
```
For Liquid templates, add Solid too:
```elixir
{:solid, "~> 1.0"}
```
For Markdown templates, add MDEx too (MDEx requires Elixir 1.15 or later):
```elixir
{:mdex, "~> 0.13.5"}
```
## Usage
```elixir
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`, `.liquid`, or `.md` out of the box).
## Liquid
Use standard Liquid objects, tags, and filters directly in `.liquid` files.
Elixir atom keys are recursively converted to Liquid's string keys.
```elixir
get("/catalog", fn conn ->
render(conn, "catalog.liquid",
shop: %{name: "Tiny Store"},
products: [
%{title: "Mug", available: true},
%{title: "Poster", available: false}
]
)
end)
```
```liquid
{% 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 %}
```
```liquid
{% 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:
```liquid
<!doctype html>
<title>{{ title | escape }}</title>
<main>{{ inner_content }}</main>
```
Run the complete example included in this package with:
```bash
mix run examples/liquid.exs
```
For a real Francis route you can open in a browser—and later deploy—run the
[storefront example](examples/storefront/README.md):
```bash
cd examples/storefront
mix deps.get
mix run --no-halt
```
### Solid options and Shopify extensions
Pass strict mode, custom filters, custom tags, matchers, caching, or a custom
partial file system through the Liquid engine config:
```elixir
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.
## Markdown
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:
```elixir
get("/journal", fn conn ->
render(conn, "journal.md", [title: "Field notes"],
layout: "layout.html.eex"
)
end)
```
```markdown
# 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:
```elixir
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](examples/storefront/README.md) includes a Markdown
journal route wrapped in its Liquid layout:
```bash
cd examples/storefront
mix deps.get
mix run --no-halt
# open http://localhost:4100/journal
```
## 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:
```eex
<%# 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:
```elixir
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:
```elixir
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
Implement `FrancisTemplate.Engine` and register it for another extension:
```elixir
defmodule MyApp.UpcaseEngine do
@behaviour FrancisTemplate.Engine
@impl true
def render(path, _assigns), do: path |> File.read!() |> String.upcase()
end
```
```elixir
# config/config.exs
config :francis_template, engines: %{"up" => MyApp.UpcaseEngine}
```
Built-in engines can be overridden through the same config.
## 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:
```eex
<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
```elixir
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.
## License
MIT