2021-01-17: EDE a template engine

The question of selecting a template engine raises quite often. And today I want to describe the library we use at work and why do I use that. The library provides a nice way to render dynamic templates.

Firstly, let's make the scope of the task more narrow to reduce the number of potential solutions:

  1. We want a powerful language that would have control structures and can render a user data.
  2. We want to render the data using a template that may not be known at runtime; it would be read from the external source and potentially updated at the runtime.

It sets some constraints; for example, we can't use Shakespeare as a powerful library, as it works at compile time. We have to understand that we give up some type checking properties by having this functionality.

So, meet the guest ED-E library!

ED-E is a nice library that can render data based on the templates with syntax similar to Jinja2.  It's widely known so a person who is not aware of Haskell can edit and update templates. And this is good as you can pass the text directly to the i18n team. 

It quite safe it does not evaluate arbitrary code at runtime and provide a basic set of filters and predicates; however, you can add your own.

It stateless rendering and applying passes are separated so you can load and compile template ahead of time to reduce application costs.

Unfortunately, there is no full-featured documentation, but everything can be read from the main module haddock and filters module haddock but readme mentions that you can take a look at tests as well.

Let's try and use it?

To use ede add it to `build-depends` of your library.

To use the library in your module add.

import qualified Text.EDE as EDE

To parse the data, there is a family of functions. parse*

parsepure ByteStringnodefault
parseIOpure ByteStringdirectorydefault
parseFilefile contentnodefault
parseFileWithfile contentdirectoryoverridable
parseWithpure ByteStringcustomoverridable

In this example, we will use default syntax but will use a template from the file, and now includes so that we will use parseFile:

import System.Exit (die)

main :: IO ()
main = do
  EDE.parseFile "template.tpl" >>= \case
     Success x -> undefined x
     Failure doc -> die (show doc)

If you run that you'll get an error message file template.tpl doesn't exist. If you write a wrong template file you'll get a pretty descriptive error, for example:

template.tpl:1:1: error: whitespace significant, expected: "{!", "{{",
    end of input, new-line, space
1 | {% sdf
  | ^


Now we can actually render something. Let's render email data, that we want to send to some known user with a confirmation code and some additional details. To render the data, you should use render or renderWith function, the latter can take additional filters defined in your application, but more on that later.

  :: Template	 -- ^ Parsed Template to render.
  -> Object	     -- ^ Bindings to make available in the environment.
  -> Result Text -- ^ Render an Object using the supplied Template.

Here Object is just an type Object from the aeson library (i.e. `HashMap Text Value`). You can get it using fromValue :: Value -> Maybe Object. Or building directly using fromPairs

import Text.EDE ((.=), fromPairs)
import qualified Data.Text as T
import qualified Data.Text.Lazy.IO as TL

main :: IO ()
main = do
  EDE.parseFile "template.tpl" >>= \case
     EDE.Success tpl -> do
       let input = fromPairs
             [ "userName" .= ("Alexander" :: T.Text)
             , "code" .= ("32167" :: T.Text)
             , "purchases" .=
                 [ "heroes 2"::T.Text
                 , "starcraft 2"
                 , "little big adventure 2"
       case EDE.render tpl input of
         EDE.Success result -> TL.putStrLn result
         EDE.Failure doc -> die (show doc)
     EDE.Failure doc -> die (show doc)

And with a template:

Hello {{ userName }},

You've just purchased
   {% for p in purchases %}
   <li> {% if p.last %} and last but not the least{% endif %} {{p.index}}: {{p.value | toUpper}} </li>
   {% endfor %}

You get:

$ cabal run
Hello Alexander,

You've just purchased
   <li>  1: HEROES 2 </li>
   <li>  2: STARCRAFT 2 </li>
   <li>  and last but not the least 3: LITTLE BIG ADVENTURE 2 </li>

So here we used basic control structures if and for with additional properties they add and a filter toUpper.

If you want to add your own filters you can do that quite easily, for example in our codebase we had:

markdownReplace :: Term
markdownReplace = TLam $ \(TVal (String s)) -> pure $ TVal $ String $ telegramMarkdownV2Escape s

That escaped all markdown characters that Telegram messenger does not accept in a string value.

So I hope that if you need a template library, you'll find this one useful.