Compare commits

...
This repository has been archived on 2023-07-11. You can view files and clone it, but cannot push or open issues or pull requests.

19 commits

Author SHA1 Message Date
Gered ba78333c28 add :tag-symbols option 2014-07-02 17:05:52 -04:00
Gered 5a644a92d2 update the way JtwigTemplates are created so the JtwigConfiguration object can be passed in for each case 2014-06-20 09:45:02 -04:00
Gered 159918019a minor argument declaration cleanup 2014-06-19 18:32:02 -04:00
Gered 1fbfaae934 add unit tests for strict-mode 2014-06-19 18:24:59 -04:00
Gered 3234d97ce0 whoops. this is why one should test code before commiting! 2014-06-19 18:14:27 -04:00
Gered 77a10a38d7 add strict-mode option support 2014-06-19 18:11:39 -04:00
Gered 68c2beb863 whitespace 2014-06-19 18:11:20 -04:00
Gered ee08d7e94d convert hard-coded web root resource path to a configurable option 2014-06-19 17:38:56 -04:00
Gered b1a605e758 this should probably be false by default (non-standard "magic") 2014-06-19 17:34:45 -04:00
Gered 97281d6648 some minor cleanups 2014-06-19 17:34:05 -04:00
Gered e1a08429bb don't need to pass empty/nil model maps in these tests anymore 2014-06-19 08:29:42 -04:00
Gered 0aeae17824 update tests 2014-06-19 08:26:19 -04:00
Gered 941c0ad2e2 deftwigfn/deftwigaliasedfn should return the function handler created 2014-06-19 08:26:06 -04:00
Gered 3ab6c96c93 make the model map arg optional 2014-06-19 07:56:16 -04:00
Gered eba27bc60d fix deftwigfn/deftwigaliasedfn macros 2014-06-19 07:54:04 -04:00
Gered f1558b72a1 update core template rendering, and big changes to function handling 2014-06-18 09:48:10 -04:00
Gered d3eb6e844c add TemplateFunction java interface
needed as a java interface due to clojure not allowing for vararg
parameters in definterface/defprotocol
2014-06-15 13:36:13 -04:00
Gered 0891fa5675 reorg src dir to allow for some java source files 2014-06-15 13:33:45 -04:00
Gered fde89f46b9 update to snapshot version of jtwig 2014-06-15 13:33:24 -04:00
15 changed files with 477 additions and 428 deletions

View file

@ -1,10 +1,12 @@
(defproject clj-jtwig "0.5.1"
:description "Clojure wrapper for JTwig"
:description "Clojure wrapper for Jtwig"
:url "https://github.com/gered/clj-jtwig"
:license {:name "Apache License, Version 2.0"
:url "http://www.apache.org/licenses/LICENSE-2.0"}
:repositories [["sonatype" {:url "http://oss.sonatype.org/content/repositories/releases"
:snapshots false}]]
:dependencies [[org.clojure/clojure "1.6.0"]
[com.lyncode/jtwig-core "2.1.7"]
[org.apache.commons/commons-lang3 "3.1"]])
[com.lyncode/jtwig-core "3.0.0-SNAPSHOT"]
[org.apache.commons/commons-lang3 "3.1"]]
:source-paths ["src/clojure"]
:java-source-paths ["src/java"])

View file

@ -1,203 +0,0 @@
(ns clj-jtwig.standard-functions
"standard function definitions. these are functions that are not yet included in JTwig's standard function
library and are just here to fill in the gaps for now."
(:import (org.apache.commons.lang3.text WordUtils)
(org.apache.commons.lang3 StringUtils))
(:use [clojure.pprint]
[clj-jtwig.options]))
(defn- possible-keyword-string [x]
(if (and (:auto-convert-map-keywords @options)
(string? x))
(keyword x)
x))
; we are using a separate map to hold the standard functions instead of using deftwigfn, etc. because doing it this
; way makes it easy to re-add all these functions when/if the JTwig function repository object needs to be
; recreated (e.g. during unit tests).
; the keys are function names. each value is a map containing :fn which is the actual function, and an optional
; :aliases, which should be a vector of strings containing one or more possible aliases for this function.
(defonce standard-functions
{"blank_if_null"
{:fn (fn [x]
(if (nil? x) "" x))
:aliases ["nonull"]}
"butlast"
{:fn (fn [sequence]
; matching behaviour of jtwig's first/last implementation
(if (map? sequence)
(-> sequence vals butlast)
(butlast sequence)))}
"center"
{:fn (fn [s size & [padding-string]]
(StringUtils/center s size (or padding-string " ")))}
"contains"
{:fn (fn [coll value]
(cond
(map? coll) (contains? coll (possible-keyword-string value))
(string? coll) (.contains coll value)
; explicit use of '=' to allow testing for falsey values
(coll? coll) (not (nil? (some #(= value %) coll)))
:else (throw (new Exception (str "'contains' passed invalid collection type: " (type coll))))))}
"dump"
{:fn (fn [x]
(with-out-str
(clojure.pprint/pprint x)))}
"dump_table"
{:fn (fn [x]
(with-out-str
(clojure.pprint/print-table x)))}
"index_of"
{:fn (fn [coll value]
(cond
(instance? java.util.List coll) (.indexOf coll value)
(string? coll) (.indexOf coll (if (char? value) (int value) value))
:else (throw (new Exception (str "'index_of' passed invalid collection type: " (type coll))))))}
"last_index_of"
{:fn (fn [coll value]
(cond
(instance? java.util.List coll) (.lastIndexOf coll value)
(string? coll) (.lastIndexOf coll (if (char? value) (int value) value))
:else (throw (new Exception (str "'last_index_of' passed invalid collection type: " (type coll))))))}
"max"
{:fn (fn [& numbers]
(if (coll? (first numbers))
(apply max (first numbers))
(apply max numbers)))}
"min"
{:fn (fn [& numbers]
(if (coll? (first numbers))
(apply min (first numbers))
(apply min numbers)))}
"normalize_space"
{:fn (fn [s]
(StringUtils/normalizeSpace s))}
"nth"
{:fn (fn [sequence index & optional-not-found]
(let [values (if (map? sequence) ; map instance check to match behaviour of jtwig's first/last implementation
(-> sequence vals)
sequence)]
(if optional-not-found
(nth values index (first optional-not-found))
(nth values index))))}
"pad_left"
{:fn (fn [s size & [padding-string]]
(StringUtils/leftPad s size (or padding-string " ")))}
"pad_right"
{:fn (fn [s size & [padding-string]]
(StringUtils/rightPad s size (or padding-string " ")))}
"random"
{:fn (fn [& values]
(let [first-value (first values)]
(cond
(and (= (count values) 1)
(coll? first-value))
(rand-nth first-value)
(> (count values) 1)
(rand-nth values)
(string? first-value)
(rand-nth (seq first-value))
(number? first-value)
(rand-int first-value)
:else
(rand))))}
"range"
{:fn (fn [low high & [step]]
(range low high (or step 1)))}
"repeat"
{:fn (fn [s n]
(StringUtils/repeat s n))}
"rest"
{:fn (fn [sequence]
; matching behaviour of jtwig's first/last implementation
(if (map? sequence)
(-> sequence vals rest)
(rest sequence)))}
"second"
{:fn (fn [sequence]
; matching behaviour of jtwig's first/last implementation
(if (map? sequence)
(-> sequence vals second)
(second sequence)))}
"sort"
{:fn (fn [sequence]
(sort < sequence))}
"sort_descending"
{:fn (fn [sequence]
(sort > sequence))
:aliases ["sort_desc"]}
"sort_by"
{:fn (fn [coll k]
(let [sort-key (possible-keyword-string k)]
(sort-by #(get % sort-key) coll)))}
"sort_descending_by"
{:fn (fn [coll k]
(let [sort-key (possible-keyword-string k)]
(sort-by #(get % sort-key) #(compare %2 %1) coll)))
:aliases ["sort_desc_by"]}
"to_double"
{:fn (fn [x]
(Double/parseDouble x))}
"to_float"
{:fn (fn [x]
(Float/parseFloat x))}
"to_int"
{:fn (fn [x]
(Integer/parseInt x))}
"to_keyword"
{:fn (fn [x]
(keyword x))}
"to_long"
{:fn (fn [x]
(Long/parseLong x))}
"to_string"
{:fn (fn [x]
(cond
(keyword? x) (name x)
(instance? clojure.lang.LazySeq x) (str (seq x))
(coll? x) (str x)
:else (.toString x)))}
"wrap"
{:fn (fn [s length & [wrap-long-words? new-line-string]]
(WordUtils/wrap
s
length
new-line-string
(if (nil? wrap-long-words?)
false
wrap-long-words?)))}})

View file

@ -1,8 +1,12 @@
(ns clj-jtwig.core
"wrapper functions for working with JTwig from clojure"
"wrapper functions for working with Jtwig from clojure"
(:import (com.lyncode.jtwig JtwigTemplate JtwigContext JtwigModelMap)
(com.lyncode.jtwig.resource ClasspathJtwigResource)
(com.lyncode.jtwig.tree.api Content)
(com.lyncode.jtwig.content.api Renderable)
(com.lyncode.jtwig.configuration JtwigConfiguration)
(com.lyncode.jtwig.parser.config TagSymbols)
(com.lyncode.jtwig.render RenderContext)
(com.lyncode.jtwig.render.config RenderConfiguration)
(com.lyncode.jtwig.resource ClasspathJtwigResource StringJtwigResource FileJtwigResource)
(java.io File FileNotFoundException ByteArrayOutputStream)
(java.net URL))
(:require [clojure.walk :refer [stringify-keys]])
@ -10,6 +14,8 @@
[clj-jtwig.utils]
[clj-jtwig.options]))
(defonce configuration (JtwigConfiguration.))
(declare flush-template-cache!)
(defn set-options!
@ -19,31 +25,45 @@
see clj-jtwig.options for the option keys you can specify here."
[& opts]
(doseq [[k v] (apply hash-map opts)]
(if (= :cache-compiled-templates k)
(cond
(= k :cache-compiled-templates)
; always clear the cache when toggling. this will help ensure that any possiblity of weird behaviour from
; leftover stuff being stuck in the cache pre-toggle-on/off won't happen
(flush-template-cache!))
(flush-template-cache!)
(= k :strict-mode)
(-> configuration .render (.strictMode v))
(= k :tag-symbols)
(-> configuration
.parse
(.withSymbols (condp = v
:default TagSymbols/DEFAULT
:js TagSymbols/JAVASCRIPT))))
(swap! options assoc k v)))
; cache of compiled templates. key is the file path. value is a map with :last-modified which is the source file's
; last modification timestamp and :template which is a com.lyncode.jtwig.tree.api.Content object which has been
; last modification timestamp and :template which is a com.lyncode.jtwig.content.api.Renderable object which has been
; compiled already and can be rendered by calling it's 'render' method
(defonce compiled-templates (atom {}))
(defn- compile-template-string [^String contents]
(->> contents
(new JtwigTemplate)
(-> contents
(StringJtwigResource.)
(JtwigTemplate. configuration)
(.compile)))
(defn- compile-template-file [^File file]
(if (inside-jar? file)
(->> (.getPath file)
(-> (.getPath file)
(get-jar-resource-filename)
(new ClasspathJtwigResource)
(new JtwigTemplate)
(ClasspathJtwigResource.)
(JtwigTemplate. configuration)
(.compile))
(->> file
(new JtwigTemplate)
(-> file
(FileJtwigResource.)
(JtwigTemplate. configuration)
(.compile))))
(defn- newer? [^File file other-timestamp]
@ -119,33 +139,32 @@
(new JtwigContext model-map-obj @functions)))
(defn- render-compiled-template
[^Content compiled-template model-map]
(let [context (make-context model-map)]
; technically we don't have to use with-open with a ByteArrayOutputStream but if we later
; decide to use another OutputStream implementation, this is already all set up :)
[^Renderable renderable model-map]
(with-open [stream (new ByteArrayOutputStream)]
(.render compiled-template stream context)
(let [context (make-context model-map)
render-context (RenderContext/create (.render configuration) context stream)]
(.render renderable render-context)
(.toString stream))))
(defn render
"renders a template contained in the provided string, using the values in model-map
as the model for the template. templates rendered using this function are always
parsed, compiled and rendered. the compiled results are never cached."
[s model-map]
(let [compiled-template (compile-template-string s)]
(render-compiled-template compiled-template model-map)))
[^String s & [model-map]]
(let [renderable (compile-template-string s)]
(render-compiled-template renderable model-map)))
(defn render-file
"renders a template from a file, using the values in model-map as the model for the template"
[^String filename model-map]
[^String filename & [model-map]]
(let [file (new File filename)
compiled-template (compile-template! file)]
(render-compiled-template compiled-template model-map)))
renderable (compile-template! file)]
(render-compiled-template renderable model-map)))
(defn render-resource
"renders a template from a resource file, using the values in the model-map as the model for
the template."
[^String filename model-map]
[^String filename & [model-map]]
(if-let [resource-filename (get-resource-path filename)]
(render-file (.getPath resource-filename) model-map)
(throw (new FileNotFoundException (str "Template file \"" filename "\" not found.")))))

View file

@ -0,0 +1,34 @@
(ns clj-jtwig.function-utils
"utility macros for creating Jtwig function handlers. intended for internal clj-jtwig use only."
(:import (com.lyncode.jtwig.functions.exceptions FunctionException)
(com.lyncode.jtwig.functions.annotations JtwigFunction Parameter)
(clj_jtwig TemplateFunction))
(:require [clj-jtwig.convert :refer [java->clojure clojure->java]]))
(defmacro make-function-handler [name aliases f]
(let [arguments (with-meta
(gensym "arguments#")
`{Parameter {}})]
`(reify TemplateFunction
(~(with-meta
'execute
`{JtwigFunction {:name ~name :aliases ~aliases}})
[_ ~arguments]
(try
(clojure->java (apply ~f (map java->clojure ~arguments)))
(catch Exception ex#
(throw (new FunctionException ex#))))))))
(defmacro deflibrary [name & function-handlers]
`(def ~(symbol name)
[~@function-handlers]))
(defmacro library-function
[fn-name args & body]
`(let [f# (fn ~args ~@body)]
(make-function-handler ~fn-name [] f#)))
(defmacro library-aliased-function
[fn-name aliases args & body]
`(let [f# (fn ~args ~@body)]
(make-function-handler ~fn-name ~aliases f#)))

View file

@ -1,37 +1,27 @@
(ns clj-jtwig.functions
"custom template function/filter support functions."
(:import (com.lyncode.jtwig.functions JtwigFunction)
(com.lyncode.jtwig.functions.repository DefaultFunctionRepository)
(com.lyncode.jtwig.functions.exceptions FunctionNotFoundException FunctionException))
(:require [clj-jtwig.convert :refer [java->clojure clojure->java]])
(:import (com.lyncode.jtwig.functions.repository FunctionResolver)
(com.lyncode.jtwig.functions.exceptions FunctionNotFoundException FunctionException)
(com.lyncode.jtwig.functions.annotations JtwigFunction)
(com.lyncode.jtwig.functions.parameters GivenParameters)
(clj_jtwig TemplateFunction))
(:require [clj-jtwig.convert :refer [java->clojure clojure->java]]
[clj-jtwig.function-utils :refer [make-function-handler]])
(:use [clj-jtwig.standard-functions]
[clj-jtwig.web.web-functions]))
(defn- make-function-handler [f]
(reify JtwigFunction
(execute [_ arguments]
(try
(clojure->java (apply f (map java->clojure arguments)))
(catch Exception ex
(throw (new FunctionException ex)))))))
(def ^:private object-array-type (Class/forName "[Ljava.lang.Object;"))
(defn- make-aliased-array [aliases]
(let [n (count aliases)
array (make-array String n)]
(doseq [index (range n)]
(aset array index (nth aliases index)))
array))
(def ^:private function-parameters (doto (GivenParameters.)
(.add (to-array [object-array-type]))))
(defn- add-function-library! [repository functions]
(doseq [[name {:keys [aliases fn]}] functions]
(.add repository
(make-function-handler fn)
name
(make-aliased-array aliases)))
(doseq [fn-obj functions]
(.store repository fn-obj))
repository)
(defn- create-function-repository []
(doto (new DefaultFunctionRepository (make-array JtwigFunction 0))
(doto (new FunctionResolver)
(add-function-library! standard-functions)
(add-function-library! web-functions)))
@ -44,36 +34,34 @@
[]
(reset! functions (create-function-repository)))
(defn function-exists? [^String name]
; intended for internal-use only. mainly exists for use in unit tests
(defn get-function [^String name]
(try
(.retrieve @functions name)
true
(catch FunctionNotFoundException ex
false)))
(.get @functions name function-parameters)
(catch FunctionNotFoundException ex)))
(defn add-function!
"adds a new template function under the name specified. templates can call the function by the
name specified (or one of the aliases specified) and passing in the same number of arguments
accepted by f. the return value of f is returned to the template."
([^String name f]
(add-function! name nil f))
([^String name aliases f]
(let [handler (make-function-handler f)]
(.add @functions handler name (make-aliased-array aliases))
(.retrieve @functions name))))
; intended for internal-use only. mainly exists for use in unit tests
(defn function-exists? [^String name]
(not (nil? (get-function name))))
(defmacro deftwigfn
"defines a new template function. templates can call it by by the name specified and passing in the
same number of arguments as in args. the return value of the last form in body is returned to the
template. functions defined this way have no aliases and can only be called by the name given."
[fn-name args & body]
`(do
(add-function! ~fn-name (fn ~args ~@body))))
`(let [f# (fn ~args ~@body)]
(.store
@functions
(make-function-handler ~fn-name [] f#))
(get-function ~fn-name)))
(defmacro defaliasedtwigfn
"defines a new template function. templates can call it by by the name specified (or one of the
aliases specified) and passing in the same number of arguments as in args. the return value of
the last form in body is returned to the template."
[fn-name args aliases & body]
`(do
(add-function! ~fn-name ~aliases (fn ~args ~@body))))
`(let [f# (fn ~args ~@body)]
(.store
@functions
(make-function-handler ~fn-name ~aliases f#))
(get-function ~fn-name)))

View file

@ -28,7 +28,7 @@
; part of the filename, and if so use that file instead.
; note that enabling this option does obviously incur a slight file I/O performance penalty
; whenever these functions are used
:check-for-minified-web-resources true
:check-for-minified-web-resources false
; automatically convert keyword keys in maps to/from strings as necessary when being passed
; in model-maps, when passed to Jtwig functions and when returned as values from Jtwig
@ -36,4 +36,18 @@
; having to do any manual conversions yourself and to keep your Clojure code as idiomatic
; as possible. Jtwig model-maps at the very least do require all the keys to be strings
; (not keywords) to ensure that model-map value resolution works as expected.
:auto-convert-map-keywords true}))
:auto-convert-map-keywords true
; the root path (relative to the classpath) where web resources such as js, css, images are
; located. typically in your project structure this path will be located under the
; "resources" directory.
:web-resource-path-root "public"
; when enabled, exceptions will be thrown when attempting to use variables that do not exist
:strict-mode false
; allows for changing the tag symbols used from normal Twig-style to a style more friendly
; with certain Javascript template engines (e.g. Angular).
; :default => tag: {% %}, output: {{ }}, comment: {# #}
; :js => tag: <# #>, output: <@ @>, comment: <$ $>
:tag-symbols :default}))

View file

@ -0,0 +1,164 @@
(ns clj-jtwig.standard-functions
"standard function definitions. these are functions that are not yet included in Jtwig's standard function
library and are just here to fill in the gaps for now."
(:import (org.apache.commons.lang3.text WordUtils)
(org.apache.commons.lang3 StringUtils))
(:use [clojure.pprint]
[clj-jtwig.function-utils]
[clj-jtwig.options]))
(defn- possible-keyword-string [x]
(if (and (:auto-convert-map-keywords @options)
(string? x))
(keyword x)
x))
(deflibrary standard-functions
(library-aliased-function "blank_if_null" ["nonull"] [x]
(if (nil? x) "" x))
(library-function "butlast" [sequence]
; matching behaviour of jtwig's first/last implementation
(if (map? sequence)
(-> sequence vals butlast)
(butlast sequence)))
(library-function "center" [s size & [padding-string]]
(StringUtils/center s size (or padding-string " ")))
(library-function "contains" [coll value]
(cond
(map? coll) (contains? coll (possible-keyword-string value))
(string? coll) (.contains coll value)
; explicit use of '=' to allow testing for falsey values
(coll? coll) (not (nil? (some #(= value %) coll)))
:else (throw (new Exception (str "'contains' passed invalid collection type: " (type coll))))))
(library-function "dump" [x]
(with-out-str
(clojure.pprint/pprint x)))
(library-function "dump_table" [x]
(with-out-str
(clojure.pprint/print-table x)))
(library-function "index_of" [coll value]
(cond
(instance? java.util.List coll) (.indexOf coll value)
(string? coll) (.indexOf coll (if (char? value) (int value) value))
:else (throw (new Exception (str "'index_of' passed invalid collection type: " (type coll))))))
(library-function "last_index_of" [coll value]
(cond
(instance? java.util.List coll) (.lastIndexOf coll value)
(string? coll) (.lastIndexOf coll (if (char? value) (int value) value))
:else (throw (new Exception (str "'last_index_of' passed invalid collection type: " (type coll))))))
(library-function "max" [& numbers]
(if (coll? (first numbers))
(apply max (first numbers))
(apply max numbers)))
(library-function "min" [& numbers]
(if (coll? (first numbers))
(apply min (first numbers))
(apply min numbers)))
(library-function "normalize_space" [s]
(StringUtils/normalizeSpace s))
(library-function "nth" [sequence index & [optional-not-found]]
(let [values (if (map? sequence) ; map instance check to match behaviour of jtwig's first/last implementation
(-> sequence vals)
sequence)]
(if optional-not-found
(nth values index optional-not-found)
(nth values index))))
(library-function "pad_left" [s size & [padding-string]]
(StringUtils/leftPad s size (or padding-string " ")))
(library-function "pad_right" [s size & [padding-string]]
(StringUtils/rightPad s size (or padding-string " ")))
(library-function "random" [& values]
(let [first-value (first values)]
(cond
(and (= (count values) 1)
(coll? first-value))
(rand-nth first-value)
(> (count values) 1)
(rand-nth values)
(string? first-value)
(rand-nth (seq first-value))
(number? first-value)
(rand-int first-value)
:else
(rand))))
(library-function "range" [low high & [step]]
(range low high (or step 1)))
(library-function "repeat" [s n]
(StringUtils/repeat s n))
(library-function "rest" [sequence]
; matching behaviour of jtwig's first/last implementation
(if (map? sequence)
(-> sequence vals rest)
(rest sequence)))
(library-function "second" [sequence]
; matching behaviour of jtwig's first/last implementation
(if (map? sequence)
(-> sequence vals second)
(second sequence)))
(library-function "sort" [sequence]
(sort < sequence))
(library-aliased-function "sort_descending" ["sort_desc"] [sequence]
(sort > sequence))
(library-function "sort_by" [coll k]
(let [sort-key (possible-keyword-string k)]
(sort-by #(get % sort-key) coll)))
(library-aliased-function "sort_descending_by" ["sort_desc_by"] [coll k]
(let [sort-key (possible-keyword-string k)]
(sort-by #(get % sort-key) #(compare %2 %1) coll)))
(library-function "to_double" [x]
(Double/parseDouble x))
(library-function "to_float" [x]
(Float/parseFloat x))
(library-function "to_int" [x]
(Integer/parseInt x))
(library-function "to_keyword" [x]
(keyword x))
(library-function "to_long" [x]
(Long/parseLong x))
(library-function "to_string" [x]
(cond
(keyword? x) (name x)
(instance? clojure.lang.LazySeq x) (str (seq x))
(coll? x) (str x)
:else (.toString x)))
(library-function "wrap" [s length & [wrap-long-words? new-line-string]]
(WordUtils/wrap
s
length
new-line-string
(if (nil? wrap-long-words?)
false
wrap-long-words?))))

View file

@ -5,13 +5,10 @@
(:import (java.net URI))
(:require [clj-jtwig.web.middleware :refer [*servlet-context-path*]]
[clojure.string :as str])
(:use [clj-jtwig.utils]
(:use [clj-jtwig.function-utils]
[clj-jtwig.utils]
[clj-jtwig.options]))
;; TODO: while 'public' is the default with Compojure, applications can override with something else ...
;; should make this customizable (some option added to clj-jtwig.options likely ...)
(def root-resource-path "public")
(defn- get-context-url [url]
(str *servlet-context-path* url))
@ -22,8 +19,7 @@
(defn- get-resource-modification-timestamp [^String resource-url]
(if (relative-url? resource-url)
(->> (str root-resource-path resource-url)
(->> (str (:web-resource-path-root @options) resource-url)
(get-context-url)
(get-resource-modification-date))))
@ -51,27 +47,22 @@
(minified-url? url))
url
(let [minified-url (make-minified-url url)]
(if (get-resource-path (str root-resource-path minified-url))
(if (get-resource-path (str (:web-resource-path-root @options) minified-url))
minified-url
url))))
; defined using the same type of map structure as in clj-jtwig.standard-functions
(deflibrary web-functions
(library-function "path" [url]
(get-context-url url))
(defonce web-functions
{"path"
{:fn (fn [url]
(get-context-url url))}
"stylesheet"
{:fn (fn [url & [media]]
(library-function "stylesheet" [url & [media]]
(let [fmt (if media
"<link href=\"%s\" rel=\"stylesheet\" type=\"text/css\" media=\"%s\" />"
"<link href=\"%s\" rel=\"stylesheet\" type=\"text/css\" />")
resource-path (get-minified-resource-url url)]
(format fmt (get-url-string resource-path) media)))}
(format fmt (get-url-string resource-path) media)))
"javascript"
{:fn (fn [url]
(library-function "javascript" [url]
(let [fmt "<script type=\"text/javascript\" src=\"%s\"></script>"
resource-path (get-minified-resource-url url)]
(format fmt (get-url-string resource-path))))}})
(format fmt (get-url-string resource-path)))))

View file

@ -0,0 +1,10 @@
package clj_jtwig;
import com.lyncode.jtwig.functions.exceptions.FunctionException;
// this is defined in Java only because Clojure defprotocol/definterface don't allow
// including method definitions with vararg parameters
public interface TemplateFunction {
public abstract Object execute (Object... arguments) throws FunctionException;
}

View file

@ -167,15 +167,15 @@
(set-options! :auto-convert-map-keywords true)
(is (= (render "{{keys_are_all_keywords(x)}}" {:x {:a "foo" :b "bar" :c "baz"}})
"true"))
"1"))
(is (= (render "{{keys_are_all_keywords(x)}}" {:x {"a" "foo" "b" "bar" "c" "baz"}})
"true"))
"1"))
(set-options! :auto-convert-map-keywords false)
(is (= (render "{{keys_are_all_keywords(x)}}" {"x" {:a "foo" :b "bar" :c "baz"}})
"true"))
"1"))
(is (= (render "{{keys_are_all_strings(x)}}" {"x" {"a" "foo" "b" "bar" "c" "baz"}})
"true"))
"1"))
(set-options! :auto-convert-map-keywords true)
(reset-functions!))))
@ -195,16 +195,16 @@
(every? string? (keys x)))
(set-options! :auto-convert-map-keywords true)
(is (= (render "{{keys_are_all_keywords(get_map_with_keywords(null))}}" {})
"true"))
(is (= (render "{{keys_are_all_keywords(get_map_with_strings(null))}}" {})
"true"))
(is (= (render "{{keys_are_all_keywords(get_map_with_keywords(null))}}")
"1"))
(is (= (render "{{keys_are_all_keywords(get_map_with_strings(null))}}")
"1"))
(set-options! :auto-convert-map-keywords false)
(is (= (render "{{keys_are_all_keywords(get_map_with_keywords(null))}}" {})
"true"))
(is (= (render "{{keys_are_all_strings(get_map_with_strings(null))}}" {})
"true"))
(is (= (render "{{keys_are_all_keywords(get_map_with_keywords(null))}}")
"1"))
(is (= (render "{{keys_are_all_strings(get_map_with_strings(null))}}")
"1"))
(set-options! :auto-convert-map-keywords true)
(reset-functions!))))

View file

@ -1,13 +1,14 @@
(ns clj-jtwig.core-test
(:import (java.io FileNotFoundException))
(:import (java.io FileNotFoundException)
(com.lyncode.jtwig.exception CalculateException RenderException))
(:require [clojure.test :refer :all]
[clj-jtwig.core :refer :all]
[clj-jtwig.functions :refer :all]))
; The purpose of these tests is to establish that our wrapper around JTwig works. That is,
; The purpose of these tests is to establish that our wrapper around Jtwig works. That is,
; we will be focusing on stuff like making sure that passing Clojure data structures
; (e.g. maps, vectors, lists) over to JTwig works fine.
; JTwig includes its own test suite which tests actual template parsing and evaluation
; (e.g. maps, vectors, lists) over to Jtwig works fine.
; Jtwig includes its own test suite which tests actual template parsing and evaluation
; functionality, so there's no point in duplicating that kind of testing here once we
; establish that the above mentioned stuff works fine.
;
@ -20,9 +21,8 @@
{:name "Bob"})
"Hello Bob!")
"passing a model-map")
(is (= (render "Hello {{ name }}!"
nil)
"Hello null!")
(is (= (render "Hello {{ name }}!")
"Hello !")
"not passing a model-map")
(is (= (render "Hello {{ name }}!"
{"name" "Bob"})
@ -49,7 +49,7 @@
(set-options! :auto-convert-map-keywords true))))
(deftest passing-model-map-data
(testing "Passing Clojure data structures to JTwigContext's"
(testing "Passing Clojure data structures to JtwigContext's"
(is (= (render "float {{ x }}"
{:x 3.14})
"float 3.14")
@ -60,7 +60,7 @@
"passing an integer")
(is (= (render "null {{ x }}"
{:x nil})
"null null")
"null ")
"passing a nil value")
(is (= (render "char {{ x }}"
{:x \a})
@ -108,9 +108,8 @@
(render-file invalid-filename
{:name "Bob"}))
"trying to render a file that doesn't exist")
(is (= (render-file test-filename
nil)
"Hello null from a file!")
(is (= (render-file test-filename)
"Hello from a file!")
"not passing a model-map")
(is (= (render-file test-filename
{"name" "Bob"})
@ -135,3 +134,39 @@
"passing a model-map where the keys are keywords and try skipping auto stringifying the keys")
(set-options! :auto-convert-map-keywords true)))))
(deftest options
(testing "clj-jtwig options specific tests"
(do
(set-options! :strict-mode true)
(is (thrown-with-msg?
RenderException
#"com.lyncode.jtwig.exception.CalculateException"
(render "{{ foo }}"))
"trying to output a non-existant variable under strict-mode")
(is (= (render "{{ foo }}" {:foo "bar"})
"bar")
"trying to output an existing variable under strict-mode")
(set-options! :strict-mode false)
(is (= (render "{{ foo }}")
"")
"trying to output a non-existant variable under non-strict-mode")
(is (= (render "{{ foo }}" {:foo "bar"})
"bar")
"trying to output an existing variable under non-strict-mode"))
(do
(set-options! :tag-symbols :js)
(is (= (render "@> 1 <@" {:foo "bar"})
"1")
"js-style output symbols")
(is (= (render "<# if (foo) #>bar<# endif #>" {:foo true})
"bar")
"js-style tag symbols")
(is (= (render "<$ this is a comment $>")
"")
"js-style comment symbols")
(set-options! :tag-symbols :default))))

View file

@ -1,15 +1,12 @@
(ns clj-jtwig.functions-test
(:import (com.lyncode.jtwig.functions.repository CallableFunction))
(:require [clojure.test :refer :all]
[clj-jtwig.core :refer :all]
[clj-jtwig.functions :refer :all]))
; TODO: is there a better way to test that something is an instance of some object generated by reify?
(defn valid-function-handler? [x]
(and (not (nil? x))
(-> x
(class)
(.getName)
(.startsWith "clj_jtwig.functions$make_function_handler"))))
(instance? CallableFunction x)))
(deftest template-functions
(testing "Adding custom template functions"
@ -27,7 +24,7 @@
(deftwigfn "add" [a b]
(+ a b))))
(is (= (render "{{add(1, 2)}}" nil)
(is (= (render "{{add(1, 2)}}")
"3")
"calling a custom function")
(is (= (render "{{add(a, b)}}" {:a 1 :b 2})
@ -52,13 +49,13 @@
(is (true? (function-exists? "plus")))
(is (true? (function-exists? "myAddFn")))
(is (= (render "{{add(1, 2)}}" nil)
(is (= (render "{{add(1, 2)}}")
"3")
"calling a custom function by name")
(is (= (render "{{plus(1, 2)}}" nil)
(is (= (render "{{plus(1, 2)}}")
"3")
"calling a custom function by alias")
(is (= (render "{{myAddFn(1, 2)}}" nil)
(is (= (render "{{myAddFn(1, 2)}}")
"3")
"calling a custom function by another alias")
@ -77,18 +74,17 @@
(apply + numbers))))
(is (true? (function-exists? "addAll")))
(is (= (render "{{add2(1, 2)}}" nil)
(is (= (render "{{add2(1, 2)}}")
"3")
"fixed number of arguments (correct amount)")
(is (thrown-with-msg?
(is (thrown?
Exception
#"clojure\.lang\.ArityException: Wrong number of args"
(render "{{add2(1)}}" nil)))
(is (= (render "{{addAll(1, 2, 3, 4, 5)}}" nil)
(render "{{add2(1)}}")))
(is (= (render "{{addAll(1, 2, 3, 4, 5)}}")
"15")
"variable number of arguments (non-zero)")
(is (= (render "{{addAll}}" nil)
"null")
(is (= (render "{{addAll}}")
"")
"variable number of arguments (zero)")
(reset-functions!)))
@ -137,28 +133,28 @@
; verify that the clojure function recognizes the correct types when the variable is passed via a constant
; value embedded in the template
(is (= (render "{{typename(42)}}" nil)
(is (= (render "{{typename(42)}}")
"java.lang.Integer")
"integer typename via constant value embedded in the template")
(is (= (render "{{typename(3.14)}}" nil)
(is (= (render "{{typename(3.14)}}")
"java.lang.Double")
"float typename via constant value embedded in the template")
(is (= (render "{{typename('foobar')}}" nil)
(is (= (render "{{typename('foobar')}}")
"java.lang.String")
"string typename via constant value embedded in the template")
(is (= (render "{{typename('a')}}" nil)
(is (= (render "{{typename('a')}}")
"java.lang.Character")
"char typename via constant value embedded in the template")
(is (= (render "{{typename(true)}}" nil)
(is (= (render "{{typename(true)}}")
"java.lang.Boolean")
"boolean typename via constant value embedded in the template")
(is (= (render "{{typename([1, 2, 3, 4, 5])}}" nil)
(is (= (render "{{typename([1, 2, 3, 4, 5])}}")
"clojure.lang.LazySeq")
"list typename via constant value embedded in the template")
(is (= (render "{{typename(1..5)}}" nil)
(is (= (render "{{typename(1..5)}}")
"clojure.lang.LazySeq")
"vector typename via constant value embedded in the template")
(is (= (render "{{typename({a: 1, b: 'foo', c: null})}}" nil)
(is (= (render "{{typename({a: 1, b: 'foo', c: null})}}")
"clojure.lang.PersistentArrayMap")
"map typename via constant value embedded in the template")
@ -177,7 +173,7 @@
"a")
"char via model-map")
(is (= (render "{{identity(x)}}" {:x true})
"true")
"1")
"boolean via model-map")
(is (= (render "{{identity(x)}}" {:x '(1 2 3 4 5)})
"[1, 2, 3, 4, 5]")
@ -196,29 +192,29 @@
; simple passing / returning... not doing anything exciting with the arguments
; using a constant value embedded inside the template
(is (= (render "{{identity(42)}}" nil)
(is (= (render "{{identity(42)}}")
"42")
"integer via constant value embedded in the template")
(is (= (render "{{identity(3.14)}}" nil)
(is (= (render "{{identity(3.14)}}")
"3.14")
"float via constant value embedded in the template")
(is (= (render "{{identity('foobar')}}" nil)
(is (= (render "{{identity('foobar')}}")
"foobar")
"string via constant value embedded in the template")
(is (= (render "{{identity('a')}}" nil)
(is (= (render "{{identity('a')}}")
"a")
"char via constant value embedded in the template")
(is (= (render "{{identity(true)}}" nil)
"true")
(is (= (render "{{identity(true)}}")
"1")
"boolean via constant value embedded in the template")
(is (= (render "{{identity([1, 2, 3, 4, 5])}}" nil)
(is (= (render "{{identity([1, 2, 3, 4, 5])}}")
"[1, 2, 3, 4, 5]")
"enumerated list via constant value embedded in the template")
(is (= (render "{{identity(1..5)}}" nil)
(is (= (render "{{identity(1..5)}}")
"[1, 2, 3, 4, 5]")
"list by comprehension via constant value embedded in the template")
; TODO: order of iteration through a map is undefined, the string being tested may not always be the same (wrt. order)
(is (= (render "{{identity({a: 1, b: 'foo', c: null})}}" nil)
(is (= (render "{{identity({a: 1, b: 'foo', c: null})}}")
"{b=foo, c=null, a=1}")
"map via constant value embedded in the template")
@ -232,7 +228,7 @@
"vector (iterating over a model-map var passed to a function and returned from it)")
; TODO: order of iteration through a map is undefined, the string being tested may not always be the same (wrt. order)
(is (= (render "{% for k, v in identity(x) %}{{k}}: {{v}} {% endfor %}" {:x {:a 1 :b "foo" :c nil}})
"b: foo c: null a: 1 ")
"b: foo c: a: 1 ")
"map (iterating over a model-map var passed to a function and returned from it)")
(is (= (render "{% for i in identity(x) %}{{i}} {% endfor %}" {:x #{1 2 3 4 5}})
"1 4 3 2 5 ")
@ -240,15 +236,15 @@
; iterating over passed sequence/collection type arguments passed to a custom function from a constant
; value embedded in the template and being returned
(is (= (render "{% for i in identity([1, 2, 3, 4, 5]) %}{{i}} {% endfor %}" nil)
(is (= (render "{% for i in identity([1, 2, 3, 4, 5]) %}{{i}} {% endfor %}")
"1 2 3 4 5 ")
"enumerated list (iterating over a model-map var passed to a function and returned from it)")
(is (= (render "{% for i in identity(1..5) %}{{i}} {% endfor %}" nil)
(is (= (render "{% for i in identity(1..5) %}{{i}} {% endfor %}")
"1 2 3 4 5 ")
"list by comprehension (iterating over a model-map var passed to a function and returned from it)")
; TODO: order of iteration through a map is undefined, the string being tested may not always be the same (wrt. order)
(is (= (render "{% for k, v in identity({a: 1, b: 'foo', c: null}) %}{{k}}: {{v}} {% endfor %}" nil)
"b: foo c: null a: 1 ")
(is (= (render "{% for k, v in identity({a: 1, b: 'foo', c: null}) %}{{k}}: {{v}} {% endfor %}")
"b: foo c: a: 1 ")
"map (iterating over a model-map var passed to a function and returned from it)")
(reset-functions!))))
@ -272,40 +268,40 @@
(is (true? (function-exists? "sort_descending_by"))))
(testing "blank_if_null"
(is (= (render "{{ a|blank_if_null }}" nil)
(is (= (render "{{ a|blank_if_null }}")
""))
(is (= (render "{{ a|blank_if_null }}" {:a nil})
""))
(is (= (render "{{ a|blank_if_null }}" {:a "foo"})
"foo"))
(is (= (render "{{ a|nonull }}" nil)
(is (= (render "{{ a|nonull }}")
"")))
(testing "butlast"
(is (= (render "{{ [1, 2, 3, 4, 5]|butlast }}" nil)
(is (= (render "{{ [1, 2, 3, 4, 5]|butlast }}")
"[1, 2, 3, 4]")))
(testing "center"
(is (= (render "{{ center('bat', 5) }}" nil)
(is (= (render "{{ center('bat', 5) }}")
" bat "))
(is (= (render "{{ center('bat', 3) }}" nil)
(is (= (render "{{ center('bat', 3) }}")
"bat"))
(is (= (render "{{ center('bat', 5, 'x') }}" nil)
(is (= (render "{{ center('bat', 5, 'x') }}")
"xbatx")))
(testing "contains"
(is (= (render "{{ {a: 1, b: 2, c: 3}|contains(\"b\") }}" nil)
"true"))
(is (= (render "{{ {a: 1, b: 2, c: 3}|contains(\"d\") }}" nil)
"false"))
(is (= (render "{{ [1, 2, 3, 4]|contains(2) }}" nil)
"true"))
(is (= (render "{{ [1, 2, 3, 4]|contains(5) }}" nil)
"false"))
(is (= (render "{{ \"abcdef\"|contains(\"abc\") }}" nil)
"true"))
(is (= (render "{{ \"abcdef\"|contains(\"xyz\") }}" nil)
"false")))
(is (= (render "{{ {a: 1, b: 2, c: 3}|contains(\"b\") }}")
"1"))
(is (= (render "{{ {a: 1, b: 2, c: 3}|contains(\"d\") }}")
"0"))
(is (= (render "{{ [1, 2, 3, 4]|contains(2) }}")
"1"))
(is (= (render "{{ [1, 2, 3, 4]|contains(5) }}")
"0"))
(is (= (render "{{ \"abcdef\"|contains(\"abc\") }}")
"1"))
(is (= (render "{{ \"abcdef\"|contains(\"xyz\") }}")
"0")))
(testing "dump"
(is (= (render "{{ a|dump }}" {:a [{:foo "bar"} [1, 2, 3] "hello"]})
@ -316,115 +312,114 @@
"\n| :a | :b | :c |\n|----+----+-----|\n| 1 | 2 | 3 |\n| 7 | 5 | dog |\n")))
(testing "index_of"
(is (= (render "{{ [1, 2, 3, 2, 1]|index_of(2) }}" nil)
(is (= (render "{{ [1, 2, 3, 2, 1]|index_of(2) }}")
"1"))
(is (= (render "{{ [1, 2, 3, 2, 1]|index_of(5) }}" nil)
(is (= (render "{{ [1, 2, 3, 2, 1]|index_of(5) }}")
"-1"))
(is (= (render "{{ \"abcdcba\"|index_of(\"b\") }}" nil)
(is (= (render "{{ \"abcdcba\"|index_of(\"b\") }}")
"1"))
(is (= (render "{{ \"abcdcba\"|index_of(\"z\") }}" nil)
(is (= (render "{{ \"abcdcba\"|index_of(\"z\") }}")
"-1")))
(testing "last_index_of"
(is (= (render "{{ [1, 2, 3, 2, 1]|last_index_of(2) }}" nil)
(is (= (render "{{ [1, 2, 3, 2, 1]|last_index_of(2) }}")
"3"))
(is (= (render "{{ [1, 2, 3, 2, 1]|last_index_of(5) }}" nil)
(is (= (render "{{ [1, 2, 3, 2, 1]|last_index_of(5) }}")
"-1"))
(is (= (render "{{ \"abcdcba\"|last_index_of(\"b\") }}" nil)
(is (= (render "{{ \"abcdcba\"|last_index_of(\"b\") }}")
"5"))
(is (= (render "{{ \"abcdcba\"|last_index_of(\"z\") }}" nil)
(is (= (render "{{ \"abcdcba\"|last_index_of(\"z\") }}")
"-1")))
(testing "max"
(is (= (render "{{ [2, 1, 5, 3, 4]|max }}" nil)
(is (= (render "{{ [2, 1, 5, 3, 4]|max }}")
"5"))
(is (= (render "{{ max(2, 1, 5, 3, 4) }}" nil)
(is (= (render "{{ max(2, 1, 5, 3, 4) }}")
"5")))
(testing "min"
(is (= (render "{{ [2, 1, 5, 3, 4]|min }}" nil)
(is (= (render "{{ [2, 1, 5, 3, 4]|min }}")
"1"))
(is (= (render "{{ min(2, 1, 5, 3, 4) }}" nil)
(is (= (render "{{ min(2, 1, 5, 3, 4) }}")
"1")))
(testing "normalize_space"
(is (= (render "{{ normalize_space(' hello world ') }}" nil)
(is (= (render "{{ normalize_space(' hello world ') }}")
"hello world")))
(testing "nth"
(is (= (render "{{ [1, 2, 3, 4, 5]|nth(2) }}" nil)
(is (= (render "{{ [1, 2, 3, 4, 5]|nth(2) }}")
"3"))
(is (thrown-with-msg?
(is (thrown?
Exception
#"java.lang.IndexOutOfBoundsException"
(render "{{ [1, 2, 3, 4, 5]|nth(6) }}" nil)))
(is (= (render "{{ [1, 2, 3, 4, 5]|nth(6, \"not found\") }}" nil)
(render "{{ [1, 2, 3, 4, 5]|nth(6) }}")))
(is (= (render "{{ [1, 2, 3, 4, 5]|nth(6, \"not found\") }}")
"not found")))
(testing "pad_left"
(is (= (render "{{ pad_left('bat', 5) }}" nil)
(is (= (render "{{ pad_left('bat', 5) }}")
" bat"))
(is (= (render "{{ pad_left('bat', 3) }}" nil)
(is (= (render "{{ pad_left('bat', 3) }}")
"bat"))
(is (= (render "{{ pad_left('bat', 5, 'x') }}" nil)
(is (= (render "{{ pad_left('bat', 5, 'x') }}")
"xxbat")))
(testing "pad_right"
(is (= (render "{{ pad_right('bat', 5) }}" nil)
(is (= (render "{{ pad_right('bat', 5) }}")
"bat "))
(is (= (render "{{ pad_right('bat', 3) }}" nil)
(is (= (render "{{ pad_right('bat', 3) }}")
"bat"))
(is (= (render "{{ pad_right('bat', 5, 'x') }}" nil)
(is (= (render "{{ pad_right('bat', 5, 'x') }}")
"batxx")))
(testing "random"
(is (some #{(render "{{ ['apple', 'orange', 'citrus']|random }}" nil)}
(is (some #{(render "{{ ['apple', 'orange', 'citrus']|random }}")}
["apple" "orange" "citrus"]))
(is (some #{(render "{{ \"ABC\"|random }}" nil)}
(is (some #{(render "{{ \"ABC\"|random }}")}
["A" "B" "C"])))
(testing "range"
(is (= (render "{{ range(1, 5) }}" nil)
(is (= (render "{{ range(1, 5) }}")
"[1, 2, 3, 4]"))
(is (= (render "{{ range(1, 5, 2) }}" nil)
(is (= (render "{{ range(1, 5, 2) }}")
"[1, 3]")))
(testing "repeat"
(is (= (render "{{ repeat('x', 10) }}" nil)
(is (= (render "{{ repeat('x', 10) }}")
"xxxxxxxxxx"))
(is (= (render "{{ repeat('x', 0) }}" nil)
(is (= (render "{{ repeat('x', 0) }}")
"")))
(testing "rest"
(is (= (render "{{ [1, 2, 3, 4, 5]|rest }}" nil)
(is (= (render "{{ [1, 2, 3, 4, 5]|rest }}")
"[2, 3, 4, 5]")))
(testing "second"
(is (= (render "{{ [1, 2, 3, 4, 5]|second }}" nil)
(is (= (render "{{ [1, 2, 3, 4, 5]|second }}")
"2")))
(testing "sort"
(is (= (render "{{ [2, 1, 5, 3, 4]|sort }}" nil)
(is (= (render "{{ [2, 1, 5, 3, 4]|sort }}")
"[1, 2, 3, 4, 5]")))
(testing "sort_descending"
(is (= (render "{{ [2, 1, 5, 3, 4]|sort_descending }}" nil)
(is (= (render "{{ [2, 1, 5, 3, 4]|sort_descending }}")
"[5, 4, 3, 2, 1]")))
(testing "sort_by"
(is (= (render "{{ [{a: 2}, {a: 1}, {a: 5}, {a: 3}, {a: 4}]|sort_by(\"a\") }}" nil)
(is (= (render "{{ [{a: 2}, {a: 1}, {a: 5}, {a: 3}, {a: 4}]|sort_by(\"a\") }}")
"[{a=1}, {a=2}, {a=3}, {a=4}, {a=5}]")))
(testing "sort_descending_by"
(is (= (render "{{ [{a: 2}, {a: 1}, {a: 5}, {a: 3}, {a: 4}]|sort_descending_by(\"a\") }}" nil)
(is (= (render "{{ [{a: 2}, {a: 1}, {a: 5}, {a: 3}, {a: 4}]|sort_descending_by(\"a\") }}")
"[{a=5}, {a=4}, {a=3}, {a=2}, {a=1}]")))
(testing "wrap"
(is (= (render "{{ wrap(\"Here is one line of text that is going to be wrapped after 20 columns.\", 20) }}" nil)
(is (= (render "{{ wrap(\"Here is one line of text that is going to be wrapped after 20 columns.\", 20) }}")
"Here is one line of\ntext that is going\nto be wrapped after\n20 columns."))
(is (= (render "{{ wrap(\"Here is one line of text that is going to be wrapped after 20 columns.\", 20, false, \"<br />\") }}" nil)
(is (= (render "{{ wrap(\"Here is one line of text that is going to be wrapped after 20 columns.\", 20, false, \"<br />\") }}")
"Here is one line of<br />text that is going<br />to be wrapped after<br />20 columns."))
(is (= (render "{{ wrap(\"Click here to jump to the commons website - http://commons.apache.org\", 20, false) }}" nil)
(is (= (render "{{ wrap(\"Click here to jump to the commons website - http://commons.apache.org\", 20, false) }}")
"Click here to jump\nto the commons\nwebsite -\nhttp://commons.apache.org"))
(is (= (render "{{ wrap(\"Click here to jump to the commons website - http://commons.apache.org\", 20, true) }}" nil)
(is (= (render "{{ wrap(\"Click here to jump to the commons website - http://commons.apache.org\", 20, true) }}")
"Click here to jump\nto the commons\nwebsite -\nhttp://commons.apach\ne.org"))))