Template Language Reference
TalkJS uses a custom template language. It borrows heavily from the Liquid Markup language and from Vue.js templates.
- Like Liquid, it is designed for fast and secure end-user programming, meaning it does not allow custom JavaScript code.
- Like Vue and React, it is designed to enable performant client-side rendered UI components.
A template is a piece of HTML with special attributes and special {{ expressions }}
. A representative example:
1<div class="dialog dialog-{{ error.type }}">2 <h3 t:if="{{ error.type === 'error' }}">Error</h3>3 <h3 t:else>Warning</h3>4 <p>{{ error.message | capitalize }}</p>5 <button>ok</button>6</div>
Templates are normal HTML files with two special features:
- Expressions, eg
{{ error.type }}
- Directives, eg
t:if=".."
and<t:set ../>
For the remainder of this document, our examples assume we're rendering data structured like this:
1{2 error: {3 type: "error",4 message: "Sorry no can do"5 }6}
Unlike normal HTML, TalkJS templates must be well-structured XML. Notably, every opening tag needs to have a corresponding closing tags, and tags that have no content must be self-closing, e.g. <br/>
.
This is invalid:
1<img src="hi.jpg">2<br>3<ul>4 <li>Hello5 <li>There6</ul>
Instead, do this:
1<img src="hi.jpg"/>2<br/>3<ul>4 <li>Hello</li>5 <li>There</li>6</ul>
The TalkJS template language is similar to React's JSX and Vue templates in this regard.
Expressions are valid inside attributes or in text nodes, and you typically use them to display text from the data a template is rendered with. For example, displaying text or a number is simply:
1{{ error.message }}
output:
1Sorry no can do
You can modify data using filters, separated by a pipe. A simple helper looks like this:
1{{ error.message | upcase }}
output:
1SORRY NO CAN DO
You can chain filters by adding more. Also, some filters take an argument:
1{{ error.message | t whatever : "Error: " | append: "."}}
is rendered as
1Error: Sorry no can do.
You can use all operators from the Liquid template language.
1{{ error.type == 'error' }}
becomes
1true
This is particularly useful when creating HTML with boolean attributes:
1<input type="radio" checked="{{ error.type == 'error' }}"/>2<input type="radio" checked="{{ error.type == 'warning' }}"/>
output:
1<input type="radio" checked/>2<input type="radio"/>
Expressions with more than one and
or or
operator are interpreted from right to left. Brackets are not valid inside expressions, so you cannot change the order of operations using brackets. For example:
1{{ true or false and false }}
will evaluate to
1true
because the or
is evaluated first.
All standard Liquid template filters are supported:
Filter | Description |
---|---|
append | append a string e.g. 1{{ 'foo' | append:'bar' }} #=> 'foobar' |
at_least | Limits a number to a minimum value. e.g. 1{{ 3 | at_least:4 }} #=> 4 |
at_most | Limits a number to a maximum value. e.g. 1{{ 3 | at_most:2 }} #=> 2 |
capitalize | capitalize words in the input sentence |
ceil | rounds a number up to the nearest integer, e.g. 1{{ 4.6 | ceil }} #=> 5 |
date | format a date (syntax reference) |
default | returns the given variable unless it is nil or the empty string, when it will return the given value, e.g. 1{{ undefined_variable | default: "Default value" }} #=> "Default value" |
divided_by | integer division e.g. 1{{ 10 | divided_by:3 }} #=> 3 |
downcase | convert an input string to lowercase |
escape_once | returns an escaped version of html without affecting existing escaped entities |
escape | html escape a string |
first | get the first element of the passed in array |
floor | rounds a number down to the nearest integer, e.g. 1{{ 4.6 | floor }} #=> 4 |
join | join elements of the array with certain character between them |
last | get the last element of the passed in array |
lstrip | strips all whitespace from the beginning of a string |
map | map/collect an array on a given property e.g. 1{{ users | map: "name"}} #=> ["Alice", "Bob"] |
minus | subtraction e.g. 1{{ 4 | minus:2 }} #=> 2 |
modulo | remainder, e.g. 1{{ 3 | modulo:2 }} #=> 1 |
newline_to_br | replace each newline (\n) with html break |
plus | addition e.g. 1{{ '1' | plus:'1' }} #=> 2` 1{{ 1 | plus:1 }} #=> 2 |
prepend | prepend a string e.g. 1{{ 'bar' | prepend:'foo' }} #=> 'foobar' |
remove_first | remove the first occurrence e.g. 1{{ 'barfoobar' | remove_first:'bar' }} #=> 'foobar' |
remove_last | remove the last occurence e.g. 1{{ 'barfoobar' | remove_last:'bar' }} #=> 'barfoo' |
remove | remove each occurrence e.g. 1{{ 'foobarfoobar' | remove:'foo' }} #=> 'barbar' |
replace_first | replace the first occurrence e.g. 1{{ 'barfoobar' | replace_first:'bar','foo' }} #=> 'foofoobar' |
replace_last | replace the last occurence e.g. 1{{ 'barfoobar' | replace_last:'bar', 'foo' }} #=> 'barfoofoo' |
replace | replace each occurrence e.g. 1{{ 'foofoo' | replace:'foo','bar' }} #=> 'barbar' |
reverse | reverses the passed in array |
round | rounds input to the nearest integer or specified number of decimals e.g. 1{{ 4.5612 | round: 2 }} #=> 4.56 |
rstrip | strips all whitespace from the end of a string |
size | return the size of an array or string |
slice | slice a string. Takes an offset and length, e.g. 1{{ "hello" | slice: -3, 3 }} #=> llo |
sort | sort elements of the array. You can optionally pass a key to sort by. |
split | split a string on a matching pattern e.g. 1{{ "a~b" | split:"~" }} #=> ['a','b'] |
strip_html | strip html from string |
strip_newlines | strip all newlines (\n) from string |
strip | strips all whitespace from both ends of the string |
times | multiplication e.g 1{{ 5 | times:4 }} #=> 20 |
truncate | truncate a string down to x characters. It also accepts a second parameter that will append to the string e.g. 1{{ 'foobarfoobar' | truncate: 5, '.' }} #=> 'foob.' |
truncatewords | truncate a string down to x words |
uniq | removed duplicate elements from an array, optionally using a given property to test for uniqueness |
upcase | convert an input string to uppercase |
url_encode | url encode a string |
where | Filters an array to only include objects with a given property value, or any truthy value by default. e.g. 1{{ users | where:"role", "admin" }} #=> [{"name": "Alice", "role": "admin"}] |
All of these filters have been taken verbatim from Liquid Templates and are documented in more detail on the Liquid website.
where
, map
and sort
support looking up a key deeper in objects while iterating through arrays.
For instance
1{{ users | map: "custom.displayName" }}
Will return an array, where for each user, it finds a property named custom
, and on that object, finds a property named displayName
.
If either of these properties are not defined, then the property is evaluated as nil
.
This works similarly with where
:
1{{ users | where: "custom.visibility", "visible" }}
This will return an array of users where custom.visibility
evaluates to "visible".
And with sort
:
1{{ users | sort: "custom.rank" }}
This returns the array of users, sorted by the value in custom.rank
property.
random_color
- picks a random color (in CSS color format, eg#ff0088
) for the given string. The same input always results in the same color.initials
- extracts initials from a name. Supports Latin and Cyrillic scripts, returns the first character of the entire string for any other scripts. Eg "Alice von Smith" becomes "AvS", "Иван Лобов" becomes "ИЛ".filesize
- converts a number of bytes into a human-readable file size string, eg "2 KB" or "251 MB"format_duration
- converts a number of seconds into a MM:SS or HH:MM:SS string, eg "00:26" or "01:04:43"
In addition, four filters are supported that allow rudimentary logic inside expressions:
is_falsy
- if the input is falsy (= nil or false), return trueis_truthy
- if the input is truthy (= any other value), return truethen
- if input is truthy, return argument. otherwise return nil.else
- if input is nil, return argument. otherwise return input.
Combined, then
and else
can be used as an inline conditional expression like this:
1{{ error.type === "error" | then: "Oh no!" | else: "Watch out" }}
output:
1Oh no!
is_falsy
is particularly useful in t:if
directives, which does not (currently) have a "not" operator:
1<!-- this will produce an error: -->2<div t:if="{{ !user.custom.age }}">...</div>34<!-- this will render the div if `age` is falsy (or does not exist) -->5<div t:if="{{ user.custom.age | is_falsy }}">...</div>
You cannot emit or modify HTML tags using expressions, because then the template isn't valid HTML anymore. If you try to use expressions to create HTML tags, you'll instead see quoted text:
1{{ user.customCssUrl | prepend: "<link href='" | append: "'>" }}
output:
1<link href='myfile.css'>
All directives begin with a t:
to help distinguish them from normal attributes.
You can use t:if
, t:else-if
and t:else
to render different HTML based on expressions:
1<div t:if="{{ error.type == 'error' }}">2 <h2>ERROR</h2>3 <p>{{ error.message }}</p>4</div>5<div t:else-if="{{ error.message contains 'Watch out' }}">6 <h3>Watch out</h3>7 <p>{{ error.message }}</p>8</div>9<div t:else>10 <p>{{ error.message }}</p>11</div>
output:
1<div>2 <h2>ERROR</h2>3 <p>Sorry no can do</p>4</div>
If an t:if
expression returns a falsy value, the entire element along with all its children will be omitted from the output.
You can iterate over lists of data using t:for
. Imagine a list of user data defined as follows:
1{2 users: [3 {id: "a", name: "Pete"},4 {id: "b", name: "Alice"},5 {id: "c", name: "Kerem"}6 ]7}
Display an element for each entry in users
as follows:
1<p t:for="{{ user in users }}" t:key="{{ user.id }}">2 {{ user.name }}3</p>45<!-- alternative syntax, if you need the index: -->6<p t:for="{{ (user, index) in users }}" t:key="{{ index }}">7 {{ index | plus: 1 }}. {{ user.name }}8</p>
output:
1<p t:key="a">Pete</p>2<p t:key="b">Alice</p>3<p t:key="c">Kerem</p>45<p t:key="a">1. Pete</p>6<p t:key="b">2. Alice</p>7<p t:key="c">3. Kerem</p>
To ensure that your app performs well, you must produce a t:key
attribute with a unique value for each entry. Choose a value that doesn't usually change over time, such as an ID field or something like that.
You can iterate over objects, or maps, of data with the same syntax. Imagine the case where our user data was structured as follows:
1{2 userMap: {3 a: {name: "Pete"},4 b: {name: "Alice"},5 c: {name: "Kerem"}6 }7}
You can iterate over each member of the object like this:
1<p t:for="{{ (user, id) in userMap }}" t:key="{{ id }}">2 {{ user.name }}3</p>
output:
1<p t:key="a">Pete</p>2<p t:key="b">Alice</p>3<p t:key="c">Kerem</p>
Inside a t:for
, a special variable called forloop
is set, with these elements:
Field | Type | Description |
---|---|---|
forloop.first | boolean | true if this is the first item in the list |
forloop.last | boolean | true if this is the last item in the list |
forloop.index | number | 0-based index of the current item in the list (0-based) |
forloop.rindex | number | 0-based index of the current item list, when counting down from the end |
forloop.length | number | total amount of items in the list |
forloop.parentloop | object | when in a nested loop, this is the forloop variable of the outer loop |
For example:
1<span t:for="{{ user in userMap }}" t:key="{{ user.id }}">2 {{ user.name }}<span t:if="{{ forloop.last == false }}">, </span>3</span>
output:
1<span>Pete<span>, </span></span>2<span>Alice<span>, </span></span>3<span>Kerem</span>
Note that index
, rindex
and length
are always numeric, also when iterating over the keys of an object.
If an element has both an t:if
and a t:for
attribute, the t:if
will be evaluated for every element of the list. This lets you make filtered lists:
1<p2 t:for="{{ (user, index) in users }}"3 t:if="{{ index > 0 }}" t:key="{{ user.id }}"4>5 {{user.name}}6</p>
output:
1<p t:key="b">Alice</p>2<p t:key="c">Kerem</p>
If you wish to reduce code duplication, or if you need to use t:if
/t:else-if
with complex expressions, you may want to define your own variables. This is done with the special <t:set/>
element, which adds values to the current scope.
For example:
1<t:set shortBigName="{{user.name | upcase | truncate: 20}}"/>2<div t:if="{{ shortBigName == 'ALICE'}}">3 Hi Alice!4</div>
Variables set using t:set
inside a t:for
are not available outside it. You can set multiple variables inside a single t:set
element if you want. A t:set
element produces no output.
You can combine t:set
with t:if
to conditionally set a variable:
1<t:set t:if="{{ user.age < 12 }}" age="young"/>2<t:set t:else age="old"/>
Note that the following shorthand syntax accomplishes the same:
1<t:set age="{{ user.age < 12 | then: 'young' | else: 'old' }}"/>
The industry has largely standardized on Liquid Templates for customer programmable web UIs. Why, then, do we insist on inventing our own? Here's some technical background.
Liquid templates are designed for use in server-side rendered websites. For pages that, in practice, aren't all that interactive. TalkJS, like many other modern applications, is designed as a single-page web app (or, more precisely, as a single-page web component). We ReactJS for its UI. A lot of things can change dynamically inside a chat UI: messags can be added, "marked as read" ticks can appear, messages can be edited, conversations can switch, the timestamp can update, and so on.
Imagine that we'd use Liquid templates to allow customers to customize the look and feel of, say, message balloons. Then our frontend would need to rerender the entire HTML result of their templates every time something changes. This results in bad performance, high battery usage, bad UX and, possibly, a flickery UI.
Instead, we want a template language that's as easy as Liquid Templates, but easier to use in the context of frameworks like React or Vue. One that we can convert into React components on the fly, instead of a big fat string of HTML.
The key benefit of Liquid over other template languages is that Liquid templates don't allow customers to write arbitrary JavaScript or Ruby code, but only a limited set of operations. This is key for security and forward compability, so we need to keep that. To the best of our knowledge, there is no template system out there that combines these two goals, so we built one.
We tried hard not to re-invent the wheel. We imported the Liquid expression syntax verbatim, including all filters:
1{{ user.name | upcase | truncate: 20 }}
To be able to use boolean attributes such as disabled
and checked
easily, we added the ability to evaluate boolean expressions:
1<button disabled="{{ user.age < 16 }}">Watch scary move</button>
The above is not valid in Liquid templates.
{% tags %}
are a key Liquid feature. We don't support them at all, because they make it extremely hard to convert a template to React components. For example, this is a valid Liquid template:
1<h{{ section.level }}>2 Section {{ section.index }}3</h{{ section.level }}>
It's a very powerful feature, but nigh on impossible to parse as HTML. Instead, we found that the Vue designers came up with an excellent template syntax that is much easier to represent as an in-memory component structure, which is almost as powerful.
Vue.js is an excellent framework for building single-page web applications. It includes a template language which itself must be valid HTML, except for a few magic attributes. This not only makes it extremely easy to parse, it also guarantees that a template can be mapped one-on-one to a component structure. We took that idea verbatim from Vue.
We also cherry-picked core Vue directives, such as v-if
and v-for
, except that they're renamed to t:if
and t:for
. We took their exact syntax and semantics, except that expressions aren't JavaScript like in Vue templates, but Liquid-style expressions.
Like Vue templates and React's JSX syntax, we strip all whitespace between HTML elements and the start/end of the line, to avoid rendering dummy whitespace to the UI.
We plan to open-source this language in the near future and we plan to keep making it more powerful.