Template Engine in JavaScript
Template engines allow the creation of dynamic output by combining data with pre-written templates. These engines use placeholders, or variables, in the templates that are filled in with data at runtime.
For example, suppose you have the following HTML template and the parameters object, then the output would be calculated using the parameters object.
Input Template:
<div>
{{ for products }}
<div>
<h3>{ product.title }</h3>
<p>{ product.price }</p>
<p>{ product.rating }</p>
</div>
{{ end for }}
</div>
Input parameters:
{
products: [
{ title: "Bread", price: "$5", rating: 5 },
{ title: "Butter", price: "$3", rating: 4.5}
]
}
Output by combining the template and params
<div>
<div>
<h3>Bread</h3>
<p>$5</p>
<p>5</p>
</div>
<div>
<h3>Butter</h3>
<p>$3</p>
<p>4.5</p>
</div>
</div>
As you can see above, template engines help to reuse the same code or string for different use cases by providing the parameters.
There are tons of good template engines already available in the JavaScript ecosystem like Moustache, Handlebars, Pug, etc. And there is honestly no reason to create your own and reinvent the wheel.
But, why not! Just for fun, we will be creating our own template system inspired by the template system used in the Solidity compiler called Whiskers.
Whiskers is implemented in C++, but let’s create our own JavaScript implementation for it in less than 100 lines of code.
Template Structure
Whiskers will support three types of parameters, plain variables, lists/arrays, and conditionals.
Plain parameters
These would be plain parameters that can be substituted from the input parameters.
Whiskers(`
<head>
<<title>>
</head>`, {
title: "WhiskersJS"
})
// output
<head>
WhiskersJS
</head>
Lists
Lists should be enclosed between <<#param>>…<</param>>
tags. Nested lists are also supported!
Whiskers(`
<ul>
<<#food>>
<li><<name>></li>
<</food>>
</ul>`, {
food: [
{ name: "Bread" },
{ name: "Butter" },
]
})
// output
<ul>
<li>Bread</li>
<li>Butter</li>
</ul>
Conditionals
Conditionals can be used like normal if-else statements. The conditional statements should be enclosed between <<?param>>param is truthy...<<!param>>param is falsy...<</param>>
.
The else part can be omitted, so we can have just an if statement, <<?param>>Only execute if truthy<</param>>
Whiskers(`
<<?isKid>>You are a kid<<!isKid>>You are an adult<</isKid>>`, {
isKid: true
})
// output
You are a kid
You can play around with it in the following CodePen
You can check out the full code on GitHub if you want to jump to the code directly.
Implementing Plain Parameters
We will be relying on regular expressions to help us find the parameters in the template.
So, let’s look at the regular expression for detecting the plain parameters <<param>>
.
// valid characters for parameter names
// + at the end for matching one or more characters
const paramChars = "[a-zA-Z0-9_-]+";
// regular expression object
const regex = new RegExp(
`<<(?<param>${paramChars})>>`
);
The above regex will do the following
- Match placeholders in the input string enclosed in
<<
and>>
. - The
param
group is created using the(?<param>
syntax, which defines a named capturing group that captures the parameter name.
Next is using this regex to find the parameters and substitute them with the input object.
While we are at it, first let’s create the main Whiskers
function that will be called for templating.
// takes the input template and params object as arguments
function Whiskers(inputStr = "", params = {}) {
// finding the first occurrence of a <<param>>
const match = inputStr.match(regex);
// if there are no params in the input template, return the template as is
if (!match) return inputStr;
// will be implemented later
// call the `handleVariable` function to substitute <<param>> with actual value
return handleVariable(inputStr, params, match);
}
Let’s understand the return value of the .match
method as we will heavily rely on it.
.match
returns an array with some metadata properties, see the following.
[
'<<title>>', // The entire match
'title', // The matched word enclosed in the first ()
index: 23, // The starting index of the match in the input string
input: '<html>\n<head>\n <title><<title>></title>\n</head>\n</html>', // The input string being searched
groups: { // the object contains one named capturing group param with a value of 'title'
param: 'title'
}
]
Using this, let’s substitute the parameters with the actual value.
// to be called from the Whiskers function
function handleVariable(inputStr, params, match) {
// Extract the part of the input string before the matched variable
const beforeMatch = inputStr.substring(0, match.index);
// Extract the name of the variable from the named capturing group
const variableName = match.groups.param;
// Look up the value of the variable in the params object
const variableValue = params[variableName];
// Extract the part of the input string after the matched variable
const afterMatch = inputStr.substring(match.index + match[0].length);
// Recursively call the Whiskers function on the remaining input string
const remainingString = Whiskers(afterMatch, params);
// Concatenate the parts of the input string with the variable value
const outputStr = beforeMatch + variableValue + remainingString;
// Return the updated input string
return outputStr;
}
Note that we call the Whiskers
function recursively to keep matching the next param and then add the result to the current output.
Also, the above code can be made less verbose.
function handleVariable(inputStr, params, match) {
return (
inputStr.substring(0, match.index) +
params[match.groups.param] +
Whiskers(inputStr.substring(match.index + match[0].length), params)
);
}
Implementing Lists
Lists are identified using the <<#param>><</param>>
tags. Let’s add the regular expression for it to our regex variable.
const regex = new RegExp(
`<<(?<param>${paramChars})>>|` + // add an `|` OR condition
`<<#(?<list>${paramChars})>>(?<listBody>(?:.|\\r|\\n)*?)<</\\k<list>>>` // regex for lists
);
The above regex will do the following
<<#
: Matches the opening tag of a list variable.(?<list>${paramChars})
: Captures the name of the list variable as a named group.>>
: Matches the closing tag of the list variable.(?<listBody>(?:.|\\r|\\n)*?)
: Captures the body of the list variable as a named group. The(?:.|\\r|\\n)
matches any character, including line breaks.<</\\k<list>>>
: Matches the closing tag of the list variable, which consists of the name of the list variable captured in thelist
named group.
Let’s add a check for lists in the Whiskers
function.
function Whiskers(inputStr = "", params = {}) {
const match = inputStr.match(regex);
if (!match) return inputStr;
// condition for handling lists
if (match[0].startsWith("<<#")) {
return handleList(inputStr, params, match);
}
return handleVariable(inputStr, params, match);
}
Let’s also implement the handleList
function.
function handleList(inputStr, params, match) {
// Create an empty string to hold the result
let result = "";
// Check if the list variable exists in the params object
if (params[match.groups.list]) {
// If it does, iterate over each item in the list using the map function
result = params[match.groups.list]
.map((item) => {
// Merge the current item's properties into a new params object
const newParams = { ...params, ...item };
// Extract the current item's template from the list body
const itemTemplate = match.groups.listBody;
// Call Whiskers with the current item's template and the new params object
const itemResult = Whiskers(itemTemplate, newParams);
// Return the result of calling Whiskers for this item
return itemResult;
})
.join(""); // Join the resulting array of template strings into a single string
}
// Extract the portion of the input string that comes before the list
const beforeList = inputStr.substring(0, match.index);
// Extract the portion of the input string that comes after the list
const afterList = inputStr.substring(match.index + match[0].length);
// Call Whiskers on the portion of the input string after the list
const afterListResult = Whiskers(afterList, params);
// Concatenate the beforeList, the result of the list, and the afterListResult
const finalResult = beforeList + result + afterListResult;
// Return the final concatenated string
return finalResult;
}
Implementing Conditionals
Conditionals are identified using <<?param>>true body<<!param>>false body<</param>>
. Let’s update our regex.
const regex = new RegExp(
`<<(?<param>${paramChars})>>|` + // add an `|` OR condition
`<<#(?<list>${paramChars})>>(?<listBody>(?:.|\\r|\\n)*?)<</\\k<list>>>|` + // add an `|` OR condition
`<<\\?(?<condition>${paramChars})>>(?<trueBody>(?:.|\\r|\\n)*?)(<<!\\k<condition>>>(?<falseBody>(?:.|\\r|\\n)*?))?<</\\k<condition>>>` // regex for conditonals
);
The regex does the following
<<\\?
matches the opening tag of a conditional statement, which consists of<<?
.(?<condition>${paramChars})
defines a named capture groupcondition
that matches a parameter name.(?<trueBody>(?:.|\\r|\\n)*?)
defines a named capture grouptrueBody
that matches any character (including newlines) until the first occurrence of the closing tag for the conditional statement.(<<!\\k<condition>>>(?<falseBody>(?:.|\\r|\\n)*?))?
is an optional non-capturing group that matches the closing tag for the conditional statement followed by an opening tag for the inverse of the conditional statement, which consists of<<!
and the parameter name.\k<condition>
is a backreference to the named capture groupcondition
. If this optional group is present, it defines a named capture groupfalseBody
that matches any character (including newlines) until the closing tag for the inverse of the conditional statement is found.<</\\k<condition>>>
matches the closing tag for the conditional statement.
Let’s update the Whiskers
function and add the handleConditional
function.
function Whiskers(inputStr = "", params = {}) {
const match = inputStr.match(regex);
if (!match) return inputStr;
if (match[0].startsWith("<<#")) {
return handleList(inputStr, params, match);
}
// check for conditionals
if (match[0].startsWith("<<?")) {
return handleConditional(inputStr, params, match);
}
return handleVariable(inputStr, params, match);
}
function handleConditional(inputStr, params, match) {
let result = ""; // Initialize the result string
// Check if the conditional expression is true based on the parameter value
if (params[match.groups.condition]) {
// If it's true and there's a true body, evaluate the true body
if (match.groups.trueBody) {
result = Whiskers(
match.groups.trueBody, // Use the true body as the input string
params // Pass in the parameters object
);
}
} else {
// If it's false and there's a false body, evaluate the false body
if (match.groups.falseBody) {
result = Whiskers(
match.groups.falseBody, // Use the false body as the input string
params // Pass in the parameters object
);
}
}
// Return the input string with the evaluated result string
return (
inputStr.substring(0, match.index) + // Add the characters before the match
result + // Add the evaluated result string
Whiskers(inputStr.substring(match.index + match[0].length), params) // Evaluate the rest of the input string after the match recursively
);
}
We are done! Check out the full code on GitHub.
Conclusion
It was fun building this template engine in JavaScript. Although this was a good exercise for playing with regular expressions, this should not be used in production. Thanks for reading! See you at the next one.