import thera._
val template =
"""
---
system:
name: Solar System
centralBody: Sun
planets:
- { name: "Mercury", mass: "3.30 * 10^23" }
- { name: "Mars", mass: " 6.42 * 10^23" }
- { name: "Venus", mass: "4.87 * 10^24" }
- { name: "Earth", mass: "5.97 * 10^24" }
- { name: "Uranus", mass: " 8.68 * 10^25" }
- { name: "Neptune", mass: "1.02 * 10^26" }
- { name: "Saturn", mass: " 5.68 * 10^26" }
- { name: "Jupiter", mass: "1.90 * 10^27" }
---
Hello! We are located at the ${system.name}!
The central body here is ${system.centralBody}.
The planets and their masses are as follows:
${foreach: ${system.planets}, ${planet => \
- ${planet.name} - ${planet.mass}
}}
"""
println(Thera(template).mkString)
// Hello! We are located at the Solar System!
// The central body here is Sun.
// The planets and their masses are as follows:
// - Mercury - 3.30 * 10^23
// - Mars - 6.42 * 10^23
// - Venus - 4.87 * 10^24
// - Earth - 5.97 * 10^24
// - Uranus - 8.68 * 10^25
// - Neptune - 1.02 * 10^26
// - Saturn - 5.68 * 10^26
// - Jupiter - 1.90 * 10^27
Thera is a template engine for Scala. It is intended to help people build static websites (such as ones deployed to GitHub Pages) in Scala.
Table of contents generated with markdown-toc
Requires Scala 2.13. Add the following dependency to your SBT project:
libraryDependencies += "com.akmetiuk" %% "thera" % "0.2.0-M3"
Or in Mill:
def ivyDeps = Agg(ivy"com.akmetiuk::thera:0.2.0-M3")
Or in Ammonite:
import $ivy.`com.akmetiuk::thera:0.2.0-M3`
A template consists of two parts – header and body. They are delimited by ---
. A header is formatted as Yaml and defines the variables accessible to the template body. The template body can access these variables via ${path.to.variable}
syntax. You can process the template via Thera(templateString).mkString
syntax.
val person =
"""
---
person:
name: Tom
age: 40
---
${person.name} is aged ${person.age}
"""
println(Thera(person).mkString) // Tom is aged 40
You can also create a template from a file.
val personFile = new File("person-template")
println(Thera(personFile).mkString) // Tom is aged 40
A template context is the hierarchy of variables accessible to the template when it is processed. Yaml header is parsed to such a hierarchy.
Internally the hierarchy is represented as a ValueHierarchy
. Given h: ValueHierarchy
, you can query the member variables programmatically from Scala via h("path.to.variable")
syntax. This call will return a Value
. A Value can be one of the following:
Str(value: String)
– a StringArr(value: List[Value])
- a collection of valuesFunction(f: (List[Value]) => Str)
- a function – such asforeach
function in the example at the beginning of this documentValueHierarchy
– a nested value hierarchy- Throws a RuntimeException - if the queried path doesn't point to a variable
You can create a ValueHierarchy
from Yaml, or a Scala Map
using methods defined in its companion object. If you defined a value hierarchy as an implicit value, the mkString
method of a template will implicitly pick it up and add to the template context:
val book =
"""
---
books:
masteringScala:
title: Mastering Scala
---
${books.masteringScala.title} costs \$${books.masteringScala.price}.
It was released in ${books.masteringScala.year}
"""
implicit val ctx = ValueHierarchy.names(
"books" -> ValueHierarchy.names(
"masteringScala" -> ValueHierarchy.names(
"price" -> Str("20"),
"year" -> Str("2015")
)
)
)
println(Thera(book).mkString)
// Mastering Scala costs $20.
// It was released in 2015
You can define functions, put them in the template context and call them from a template. You can do so via methods in the Function
companion object. For example:
val hiTml =
"""
${sayHi: World}
"""
implicit val ctx = ValueHierarchy.names(
"sayHi" -> Function.function[Str] { name: Str =>
Str(s"Hello ${name.value}")
}
)
println(Thera(hiTml).mkString)
// Hello World
Templates can also be functions. The argument are specified in square brackets at the top of the header. You can use them as ordinary Scala functions as follows:
val hiTml =
"""
---
[title, name]
city: Lausanne
---
Welcome to $city, $title $name!
"""
val hi: List[Value] => String = Thera(hiTml).mkFunction
println(hi(Str("Mr") :: Str("Jack") :: Nil))
// Welcome to Lausanne, Mr Jack!
Or you can make a Thera Function
out of them and pass them to other templates:
val hiTml =
"""
---
[title, name]
city: Lausanne
---
Welcome to $city, $title $name!
"""
val wrapperTml =
"""
${greetingsFun: Mr, Jack}
"""
val hi: Function = Thera(hiTml).mkValue.asFunction
implicit val ctx = ValueHierarchy.names(
"greetingsFun" -> hi
)
println(Thera(wrapperTml).mkString)
// Welcome to Lausanne, Mr Jack!
Currently the following functions are available out of the box in Thera:
id: Str => Str
– identity, evaluates to its input.foreachSep: (arr: Arr, sep: Str, f: Function) => Str
- appliesf
to every element ofarr
. Then concatenates the results while separating them withsep
.foreach: (arr: Arr, f: Function) => Str
– likeforeachSep
wheresep
is an empty string.if: (cond: Str, ifTrue: Str, ifFalse: Str) => Str
- ifcond
istrue
, evaluates toifTrue
, otherwise – toifFalse
.outdent: (size: Str, text: Str) => Str
– outdents every line oftext
bysize
. Useful when working with lambdas.
If a function you are calling accepts another function as an argument, you can define this other function inline using a lambda syntax: ${ arg1, arg2, ... => body }
. For example:
val article =
"""
---
tags: [scala, functional, programming]
---
Tags are: ${foreachSep: $tags, \, , ${x => Tag $x}}
"""
println(Thera(article).mkString)
// Tags are: Tag scala, Tag functional, Tag programming
Symbols $
, {
and }
are significant symbols for the template. If you want to use them as plain text, you need to escape them with \
, e.g. \$
.
In function calls and lambdas, we need to decide when to parse the whitespaces and when to drop them for ergonomics reasons. For example: ${foreachSep: $tags, \, , ${x => Tag $x}}
– here, the whitespace before $tags
and \,
is for convenience of reading rather than for the output. Hence, in arguments to the function calls, we always drop initial whitespaces and start the argument parsing from the first non-whitespace character.
You can modify this behavior by escaping the whitespace: ${foreachSep: $tags,\ \, , ${x => Tag $x}}
– here, the separator will be " , "
instead of ", "
.
In the bodies of lambdas, the story is similar. To start parsing the whitespaces, you need to escape them, e.g.:
${foreach: ${system.planets}, ${planet => \
- ${planet.name} - ${planet.mass}
}}
This project started as a static website generator because there wasn't one for Scala and I needed one to generate my blog. Since then, however, I realised that Scala doesn't need a static website generator. It has a powerful enough ecosystem for a user to effortlessly unroll their own logic for generating a website using existing libraries. For instance, my blog uses Ammonite and os-lib in conjunction with Pandoc, a Docker image that defines the environment with Pandoc in it and GitHub Actions that runs the Docker and deploys the website to GitHub Pages. You can have a look at the sources of the blog here.
The only missing piece in the ecosystem is a good templating engine. Thera attempts to provide such an engine for Scala. It doesn't aim to be a markdown processor or a website generator since these tasks can already be easily done using other tools.
There is a tutorial explaining how to build a blog powered by Thera and published on GitHub Pages. You can also use it as a basis to start developing your own website.
If you would like to collaborate on this project, do not hesitate to contact me about it!