# 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
