Skip to content

Latest commit

 

History

7 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

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, 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.

Installation

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

For 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"}

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, .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.

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.exs

For 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-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:

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:

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/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:

<%# 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

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.

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

About

File-based templates with layouts and pluggable engines (EEx, Liquid/Solid) for the Francis micro-framework

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages