A templating engine for Clojure
Installation
Add majavat to dependency list
[org.clojars.jj/majavat "2.5.3"]Usage
Rendering templates
Direct rendering
(:require [jj.majavat :as majavat] [jj.majavat.renderer.sanitizer :refer [->Html]]) (def render-fn (majavat/build-renderer "index.html")) ;; or build html renderer, which will sanitize input (def html-render-fn (majavat/build-html-renderer "index.html")) (def render-fn-from-string (majavat/build-string-renderer "hello {{ user }}</b>" {:sanitizer (->Html)})) (render-fn {:user "jj"}) (html-render-fn {:user "jj"}) (render-fn-from-string {:user "jj"})
Additional options can be passed with
(def render-fn (majavat/build-renderer "index.html" {:cache? false :pre-render {:key "value"} :environment {:filters {:reverse (fn [value] (string/reverse value))}} :renderer (->StringRenderer)})) (render-fn {:user "jj"})
Indirect rendering
It will cache all render-functions for user
(:require [jj.majavat.cache :as majavat-cache]) (majavat-cache/render-html "index.html" {:user "jj"})
All supported options:
| Option | Default Value | Supported Options |
|---|---|---|
renderer |
StringRenderer |
Any Renderer implementation |
cache? |
true |
true, false |
template-resolver |
ResourceResolver |
TemplateResolver |
pre-render |
{} | Map |
sanitizer |
nil | Any Sanitizer implementation |
environment |
{} | Map (see environment options) |
error-handler |
Reporting | Any ErrorHandler Implementation |
fragment |
nil | Keyword naming a fragment to render |
builder |
Chosen by cache? |
Any Builder constructor |
Environment
| Option | Default Value | Supported Options |
|---|---|---|
filters |
{} | Map |
sanitizers |
{} | Keyword -> Sanitizer Map |
dictionary |
nil | Any Dictionary implementation |
Creating templates
Inserting value
Rendering file.txt with content
Hello {{ user.name }}!
ID is: {{ user.`namespaced/user.id` }}!
(def render-fn (build-renderer "file.txt")) (render-fn {:user {:name "jj" :namespaced/user.id "foo"}}) ;; => returns "Hello world!\nID is foo"
or with a filter
Hello {{ name | upper-case }}!
(def render-fn (build-renderer "file.txt")) (render-fn {:name "world"}) ;; => returns Hello WORLD!
Built In Filters
| Filter | Type | Input | Output |
|---|---|---|---|
| abs | Number | -1 | 1.0 |
| append(" world") | String | "hello" | "hello world" |
| capitalize | String | "hello world" | "Hello world" |
| ceil | Number | 1.99 | 2 |
| date(["hh/mm"], ["Asia/Tokyo"]) | Instant | 2011-11-11T02:11:00Z | "11/11" |
| date(["yyyy"]) | LocalDate | 2025-01-15 | "2025" |
| date(["yyyy"]) | LocalDateTime | 2025-01-15T11:11 | "2025" |
| date(["hh/mm"]) | LocalTime | 11:11 | "11/11" |
| date(["hh/mm"], ["Asia/Tokyo"]) | ZonedDateTime | 2011-11-11T11:11+09:00[Asia/Tokyo] | "11/11" |
| dec | Number | 5 | 4 |
| default("foo") | nil | nil | "foo" |
| default("foo") | not nil | "bar" | "bar" |
| file-size | Number | 2048 | "2 KB" |
| first | Map | {:foo :a :bar :b :baz :c} | [:foo :a] |
| first | Sequential | (list :foo :bar :baz) | :foo |
| floor | Number | 1.4 | 1.0 |
| inc | Number | 5 | 6 |
| indent(2, [first], [blank]) | String | "a\nb" | "a\n b" |
| int | String | "123" | 123 |
| join([separator]) | Sequential | [1 2 3] | "1, 2, 3" (default sep ", ") |
| json | Any | {:a 1} | {"a":1} |
| json(2) | Any | {:a 1} | pretty-printed, 2-sp indent |
| length | Map | {:a 1 :b 2} | 2 |
| length | Sequential | [1 2 3] | 3 |
| length | String | "hello" | 5 |
| long | String | "123" | 123L |
| lower-case | String | "HELLO WORLD" | "hello world" |
| name | Keyword | :name | "name" |
| prepend(" world") | String | "hello" | "world hello" |
| replace(old, new, [count]) | String | "banana" | "bonono" |
| rest | Map | {:foo :a :bar :b :baz :c} | {:bar :b :baz :c} |
| rest | Sequential | (list :foo :bar :baz) | (list :bar :baz) |
| round | Number | 1.99 | 2 |
| slugify | String | "Foo Bar" | "foo-bar" |
| str | any | 1 | "1" |
| title-case | String | "hello world" | "Hello World" |
| trans | Keyword | :hello | "hei" (via dictionary) |
| trim | String | " hello " | "hello" |
| truncate(14, [kill], [end]) | String | "The quick brown fox" | "The quick..." |
| upper-case | String | "hello world" | "HELLO WORLD" |
| upper-roman | String | "iv" | "IV" |
Arguments shown in
[brackets]are optional.
User Provided filters Filters
Assoc :filter to option map, when building renderer, with this value
{:quote (fn [value author]
(format "\"%s\" - %s" value author))}Note: Tag a filter with
^{:context-aware true}to receive the full render context as the second argument, before any template arguments:{:quote ^{:context-aware true} (fn [value context author] (format "\"%s\" - %s (%s)" value author (:locale context)))}
Conditionals
Rendering input file with content:
"Hello {% if name %}{{name}}{% elif id %}{% else %}world{% endif %}!"
(def render-fn (build-renderer "input-file")) (render-fn {:name "jj"}) ;; returns "Hello jj!" (render-fn {:id "JJ"}) ;; returns "Hello JJ!" (render-fn {}) ;; returns "Hello world!"
or
"Hello {% if not name %}world{% else %}jj{% endif %}!"
(def render-fn (build-renderer "input-file")) (render-fn {:name "foo"}) ;; returns "Hello jj!" (render-fn {}) ;; returns "Hello world!"
or with tests
"Hello {% if value is even %}even{% else %}not even{% endif %}!"
(render-fn {:value 2}) ;; returns "Hello even!" (render-fn {:value 1}) ;; returns "Hello not even!"
Available is tests:
| test name | args | example |
|---|---|---|
| even | - | {% if value is even %} |
| odd | - | {% if value is odd %} |
| seq | - | {% if value is seq %} |
Comparison operators
| operator | example |
|---|---|
| == | {% if value == 0 %} |
| {% if value == "value" %} | |
| < | {% if value < 10 %} |
| <= | {% if value <= 10 %} |
| > | {% if value > 10 %} |
| >= | {% if value >= 10 %} |
<, <=, >, and >= compare numbers (a non-number value makes the condition
false); == compares numbers and strings.
Looping
for
Rendering input file with content:
{% for item in items %}
- {{ item }} is {{ loop.index }} of {{ loop.total }}
{% endfor %}
(def render-fn (build-renderer "input-file")) (render-fn {:items ["Apple" "Banana" "Orange"]}) ;; returns "- Apple is 0 of 3\n- Banana is 1 of 3\n- Orange is 2 of 3"
or default value
{% for item in items %}
- {{ item }} is {{ loop.index }} of {{ loop.total }}
{% empty %}
empty list
{% endfor %}
(def render-fn (build-renderer "input-file")) (render-fn {}) ;; returns "empty list"
The loop context provides access to:
loop.total - total number of items in the collection
loop.index - current 0-based index position
loop.first? - true only for the first item
loop.last? - true only for the last item
In situations where loop context is not needed, only can be used
{% for item only in items %}
- {{ item }}
{% endfor %}
(def render-fn (build-renderer "input-file")) (render-fn {:items ["Apple" "Banana" "Orange"]}) ;; returns "- Apple\n- Banana\n- Orange"
Including template
file.txt content
foo
Rendering input file with content:
included {% include "file.txt" %}
(def render-fn (build-renderer "input-file")) (render-fn {}) ;; returns "included foo"
Setting value
You can set value within a template via:
hello {% let foo = "baz" %}{{ foo }}{% endlet %}
(def render-fn (build-renderer "input-file")) (render-fn {}) ;; returns "hello baz"
or
hello {% let foo = bar %}{{ foo.baz }}{% endlet %}
(def render-fn (build-renderer "input-file")) (render-fn {:bar {:baz "baz"}}) ;; returns "hello baz"
Extending template
file.txt content
foo
{% block %}
baz
Rendering input file with content:
{% extends "file.txt" %}
bar
(def render-fn (build-renderer "input-file")) (render-fn {}) ;; returns "foo\nbar\nbaz"
Comments
input-file with content
foo{# bar baz #}
(def render-fn (build-renderer "input-file")) (render-fn {}) ;; returns "foo"
Verbatim
input-file with content
{% verbatim %}foo{{bar}}{%baz%}{#qux#}quux{% endverbatim %}
(def render-fn (build-renderer "input-file")) (render-fn {}) ;; returns "foo{{bar}}{%baz%}{#qux#}quux"
Debug
Currennt context can be printed out with debug tag
{% debug %}
(def render-fn (build-renderer "input-file")) (render-fn {:number 1}) ;; prints out "{:number 1}" to console
or if you want to write to custom Writer
{% debug writer-imp%}
and render file
(def render-fn (build-renderer "input-file")) (render-fn {:number 1 :writer-imp (java.io.StringWriter.)}) ;; prints out "{:number 1}" to console
Escape
If needed, Sanitizer implementation can be set/overridden via escape tag.
{% escape html %}foo{{bar}}{% endescape %}
(def render-fn (build-renderer "input-file")) (render-fn {:bar "<div/>"}) ;; returns "<div/>"
Available values:
- none
- html
- json
or ones provided under :environment :sanitizers
Translation
The trans tag translates a key using the configured Dictionary. The language is determined by the
:locale key in the context.
{% trans hello %}
(def render-fn (build-renderer "input-file" {:environment {:dictionary my-dictionary}})) (render-fn {:locale "fi"}) ;; returns the Finnish translation for :hello (render-fn {:locale "en"}) ;; returns the English translation for :hello
Macros
Every macro — whether built-in or user-defined — is called with parenthesised,
comma-separated arguments: {% name(arg1, arg2) %}.
Built-in macros
csrf-token
Renders a hidden CSRF input. :csrf-token has to be provided in the context; its
value is never sanitized, even inside an {% escape %} block.
{% csrf-token() %}
(def render-fn (build-renderer "input-file")) (render-fn {:csrf-token "foobarbaz"}) ;; returns <input type="hidden" name="csrf_token" value="foobarbaz">
query-string
Turns a map into a URL query string.
/foo{% query-string(foo) %}
(def render-fn (build-renderer "input-file")) (render-fn {:foo {:count 2}}) ;; returns "/foo?count=2"
now
Prints the current time, evaluated fresh on every render. Takes an optional
format and time zone; the default format is yyyy/MM/dd hh:mm and the default
zone is the system default.
default format {% now() %}
formatted {% now("yyyy-MM-dd") %}
formatted with tz {% now("yyyy-MM-dd HH:mm", "Asia/Tokyo") %}
(def render-fn (build-renderer "input-file")) (render-fn {}) ;; e.g. "default format 2011/11/11 11:11\nformatted 2011-11-11\nformatted with tz 2011-11-11 23:11"
Defining macros
{% macro foo %}foobar{{baz}}{% endmacro %}{% foo() %}{% foo() %}
{% macro greet(who) %}hello {{who}}!{% endmacro %}{% greet(user.name) %}
{% macro welcome(greeting, who) %}{{greeting}} {{who}}!{% endmacro %}{% welcome("hi", user.name) %}
(def render-fn (build-renderer "input-file")) (render-fn {:baz "baz"}) ;; returns "foobarbazfoobarbaz" (render-fn {:user {:name "alice"}}) ;; returns "hello alice!" (render-fn {:user {:name "alice"}}) ;; returns "hi alice!"
Importing macros
Macros defined in another file can be imported with {% import "macros/macros.mjvt" %}:
{% macro greet(who) %}hello {{who}}!{% endmacro %}
{% import "macros/macros.mjvt" %}{% greet(user.name) %}
(def render-fn (build-renderer "input-file")) (render-fn {:user {:name "alice"}}) ;; returns "hello alice!"
Defining a macro with a name that already exists — whether imported or defined in the current file — is a syntax error.
Fragments
A fragment marks a named region of a template that can be rendered on its own, which is useful for returning partial responses (for example an htmx swap).
<ul>{% fragment row %}<li>{{ item.name }}</li>{% endfragment %}</ul>
Rendering the whole template inlines the fragment's body, so the fragment tag
is invisible in normal output:
(def render-fn (build-renderer "input-file")) (render-fn {:item {:name "alice"}}) ;; returns "<ul><li>alice</li></ul>"
Pass a :fragment option to render only that region:
(def render-fn (build-renderer "input-file" {:fragment :row})) (render-fn {:item {:name "alice"}}) ;; returns "<li>alice</li>"
RenderTarget Protocol
render
Renders a template using the provided context.
- template - template AST
- context - Map of variables for template interpolation
- error-handler - A record that implements
ErrorHandlerprotocol
Returns - Rendered output
Built-in Implementations
StringRenderer
Returns rendered output as a String clojure
(->StringRenderer)InputStreamRenderer
Returns rendered output as an InputStream for streaming large content
(->InputStreamRenderer)PartialRenderer
Returns a partially rendered AST.
(->PartialRenderer)TemplateResolver
The TemplateResolver protocol provides a uniform interface for accessing template content from different sources.
Protocol Methods
read-template
Returns the contents of that template as a string, or nil if not found.
(read-template resolver "/templates/header.html")
template-exists?
Check if template exists at a path.
(template-exists? resolver "/templates/footer.html") ;; => true
Built-in Implementations
- ResourceResolver (default) - Reads from classpath
- FsResolver - Reads from filesystem
Sanitizer
Sanitizer protocol provides a way to sanitize and cleanup values.
Usage
(sanitize (->Html) "<foo>bar</baz>") ;; => <foo>bar</baz>
Built-in Implementations
- Html - implementation for html pages
- Json - implementation for Json
- None - Implementation that does not sanitize
Dictionary
The Dictionary protocol provides translation support for templates via the {% trans %} tag. The locale is read from
the :locale key in the rendering context.
Protocol Methods
translate
Translates a word for the given language. Returns the translated string, or nil if no translation is found.
(translate dictionary locale word)Example Implementation
(defrecord MapDictionary [translations] Dictionary (translate [_ language word] (get-in translations [language word]))) (def my-dictionary (->MapDictionary {"en" {:hello "hello" :world "world"} "fi" {:hello "hei" :world "maailma"}}))
Pass it via the environment when building a renderer:
(def render-fn (build-renderer "input-file" {:environment {:dictionary my-dictionary}})) (render-fn {:locale "en"}) ;; uses English translations (render-fn {:locale "fi"}) ;; uses Finnish translations
ErrorHandler
The ErrorHandler protocol determines how template errors (syntax errors, missing files, unsupported filters) are
handled
during rendering.
Protocol Methods
handle-error
Handles a template error. Called when the parser returns an error map instead of a valid AST.
- renderer - The renderer that encountered the error
- template - A map containing error details (
:type,:error-message, and optionally:line)
(handle-error error-handler renderer template)Built-in Implementations
- Reporting (default) - Renders the error as an HTML page showing the error type, message, and line number
- FailFast - Throws an
ExceptionInfowith the error details
Usage
(:require [jj.majavat.error-handler.fail-fast :refer [->FailFast]] [jj.majavat.error-handler.reporting :refer [->Reporting]]) (def render-fn (build-renderer "input-file" {:error-handler (->FailFast)})) (def render-fn (build-renderer "input-file" {:error-handler (->Reporting)}))
Json
The Json protocol controls how the json filter turns a value into a JSON string.
Majavat ships a built-in serializer, but you can supply your own (for example one backed by Jackson or Cheshire) and the
filter will call it instead.
Protocol Methods
to-json
Serializes a value to a JSON string.
- value - the value being serialized
- opts - a map of options (may be
nil); the built-in serializer honours{:indent n}for pretty-printing (thejson(n)filter argument), custom implementations are free to ignore it
(to-json serializer value opts)Built-in Implementation
- DefaultJsonSerializer (default) - Compact JSON with optional pretty-printing via
json(n). Handles nil, booleans, numbers (ratios as doubles,NaN/Infinityasnull), strings, keywords, maps, and sequential/set collections.
Example Implementation
(:require [jj.majavat.protocol.json :refer [Json]]) (defrecord JacksonSerializer [mapper] Json (to-json [_ value _opts] (.writeValueAsString mapper value)))
Pass it via the environment when building a renderer:
(def render-fn (build-renderer "input-file" {:environment {:json-serializer (->JacksonSerializer object-mapper)}}))
Builder
The Builder protocol decides when a template is parsed: once up front (and reused on every render) or on every
render call. The built-ins are selected by the cache? option, but you can supply your own to control
parsing yourself - for example to re-parse only when the template file changed, or to expire the parsed template after a
period.
Protocol Methods
build-renderer
Returns the render function that jj.majavat/build-renderer hands back to the caller - a function of one argument, the
render context.
- file-path - the template being built
- template-resolver - the
TemplateResolverto read templates with - renderer - the
RenderTargetthe AST is rendered into - escape-config - the
Sanitizerapplied to interpolated values - error-handler - the
ErrorHandlerto hand parse errors to
(build-renderer builder file-path template-resolver renderer escape-config error-handler)Built-in Implementations
- CachedBuilder (default,
:cache? true) - Parses the template once when the render function is built - OneShotBuilder (
:cache? false) - Parses the template on every render call
Example Implementation
A builder is created with a constructor taking [pre-render-context environment], so the :builder option takes the
constructor rather than an instance - majavat calls it with the pre-render context and the fully resolved environment
(filters, sanitizers, dictionary, json serializer, fragment).
(:require [jj.majavat.parser :as parser] [jj.majavat.protocol.builder :refer [Builder]] [jj.majavat.protocol.renderer.render-target :as render-target]) (defrecord TtlBuilder [pre-render-context environment] Builder (build-renderer [_ file-path template-resolver renderer escape-config error-handler] (let [{:keys [filters sanitizers dictionary json-serializer fragment]} environment template (atom nil)] (fn [context] (let [[parsed-at ast] (or @template [0 nil]) ast (if (< (- (System/currentTimeMillis) parsed-at) 60000) ast (let [ast (parser/parse-template file-path template-resolver filters sanitizers dictionary escape-config json-serializer fragment)] (reset! template [(System/currentTimeMillis) ast]) ast))] (render-target/render renderer ast context error-handler))))))
Pass the constructor when building a renderer:
(def render-fn (build-renderer "input-file" {:builder ->TtlBuilder}))
:builder takes precedence over cache?.
Performance
Stress test was conducted rendering template 1000000 times using a standard web page with navigation, conditionals, loops, and nested data access.
| Engine | Total Time | Per Render | Throughput | vs Majavat (String) |
|---|---|---|---|---|
| Majavat (String) | 10.8s | 10.8μs | 92,395/s | 1x (baseline) |
| Majavat (InputStream) | 16.3s | 16.3μs | 61,272/s | 1.51x slower |
| Hiccup | 22.1s | 22.1μs | 45,318/s | 2.04x slower |
| Selmer | 87.0s | 87.0μs | 11,499/s | 8.04x slower |
Available Extensions
- File Renderer - Renders output directly to file.
- TTL Builder - Reloads cache on a scheduled interval.
TODOS
- Whitespace control using
{%- -%}and{{- -}} - Boolean
andandorexpressions
License
Copyright © 2025 ruroru
This program and the accompanying materials are made available under the terms of the Eclipse Public License 2.0 which is available at https://www.eclipse.org/legal/epl-2.0/.
This Source Code may also be made available under the following Secondary Licenses when the conditions for such availability set forth in the Eclipse Public License, v. 2.0 are satisfied GNU General Public License as published by the Free Software Foundation, either version 2 of the License, or (at your option) any later version, with the GNU Classpath Exception which is available at https://www.gnu.org/software/classpath/license.html.