Skip to content

Instantly share code, notes, and snippets.

@jjantschulev
Last active May 28, 2020 03:58
Show Gist options
  • Save jjantschulev/0c62cf7eec027e98adaa46537b2bdffe to your computer and use it in GitHub Desktop.
Save jjantschulev/0c62cf7eec027e98adaa46537b2bdffe to your computer and use it in GitHub Desktop.
Fast, Simple Templating Engine. Works with any type of text. Built in node.js.

How To Use

All expressions in FastplateJS are wrapped by !( expression ) except for the end-block expression: !/

In fastplate you can do these things:

  • Insert value into text: !(path.to.value) This is the json path to your value from the passed data object. It can also be the iterator variable when inside a loop. Or it can be a function. (this function must have been defined in the data object passed to the fastplate(text, data) call).
    • If it is a function you can pass arguments after the initial function name. EG: !(load body).
    • You can also pass variables that are in scope to the function by using $. EG: !(renderAsName $user.name big). In this case the actual user name will be passed as the first argument and the literal string "big" will be passed as the second.
    • The return value of the function is inserted into the text.
  • If statement !(if <value>). Note: == and != are not supported. The value must be a boolean. (or it will be coerced into a boolean by javascript). The value can also be a function call ie !(if func arg1 arg2). To end the if block use !/
  • If else: !(if <value>) value is true !(else) value is false !/. This will output either "value is true" or "value is false" depending on the value of the variable "value"
  • For loop !(for el in <elements>). Elements must be an array, or iterator. To end the loop block use: !/

Currently syntax errors are not handled and will the fastplate(text, data) call will throw an error.

Async: Fast plate is now async, and will return a promise. All user defined functions can be async aswell, but don't have to be.

renderPage.js is provided to show how Fastplate can be used to make a simple, but powerful html templating library. Make syre to update the path in line 10, to wherever your template files are stored. The load function will take a file location relative to that path and insert that files contents into the original file. Omit the .html in the file location.

Todo:

  • error handling and proper syntax errors
  • Allow escaping of special characters.
  • expression parser for the if statement
  • if else block: !(elif <value>)......!/. Replace by !(if v) v is true !(else) !(if v2) v2 is true !(else) nothing is true !/ !/
async function fastPlate(text, data) {
const tokens = tokenise(text);
const blocks = buildBlocks(tokens);
const output = await evaluate(blocks, data);
return output;
}
async function evaluate(block, ctx) {
let context = { ...ctx };
let forLoopContext = {};
let ifContext = true;
let pIfContext = ifContext;
let output = '';
for (let element of block.val) {
if (element.type === 'block') {
if (forLoopContext.next) {
for (let el of forLoopContext.arr) {
output += await evaluate(element, { ...context, [forLoopContext.identifier]: el })
}
} else if (ifContext) {
output += await evaluate(element, context)
}
} else {
if (element.val.commandType === 'ins') {
output += await computeValueAtPath(context, element.val.commandArgs[0]);
} else if (element.val.commandType === 'for') {
forLoopContext = { next: true, arr: await computeValueAtPath(context, element.val.commandArgs[1]), identifier: element.val.commandArgs[0] }
} else if (element.val.commandType === 'endBlock') {
forLoopContext = {};
ifContext = true;
} else if (element.val.commandType === 'if') {
ifContext = await computeValueAtPath(context, element.val.commandArgs[0]);
pIfContext = ifContext;
} else if (element.val.commandType === 'else') {
ifContext = !pIfContext;
} else {
output += element.val.text;
}
}
}
return output;
}
async function computeValueAtPath(data, commandArgs) {
const value = commandArgs;
const [path, ...args] = value.split(" ");
const valueAtPath = getValueAtPath(data, path);
if (typeof valueAtPath === 'function') {
const valueAtPathOutput = valueAtPath(...(args.map(a => a[0] === '$' ? getValueAtPath(data, a.slice(1)) : a)));
return await ((async () => valueAtPathOutput)());
} else {
return valueAtPath;
}
}
function getValueAtPath(data, path) {
const elements = path.split('.');
let v = data;
for (let el of elements) {
v = v[el];
}
return v;
}
function buildBlocks(tokens) {
const mainBlock = { type: 'block', val: [] };
let currentBlock = mainBlock;
for (let i = 0; i < tokens.length; i++) {
const token = tokens[i];
if (['for', 'if'].includes(token.commandType)) {
const newBlock = { type: 'block', val: [], data: { parent: currentBlock } }
currentBlock.val.push({ type: 'token', val: token });
currentBlock.val.push(newBlock);
currentBlock = newBlock;
} else if (token.commandType === 'else') {
currentBlock = currentBlock.data.parent;
currentBlock.val.push({ type: 'token', val: token });
const newBlock = { type: 'block', val: [], data: { parent: currentBlock } }
currentBlock.val.push(newBlock);
currentBlock = newBlock;
} else if (token.commandType === 'endBlock') {
currentBlock = currentBlock.data.parent;
currentBlock.val.push({ type: 'token', val: token });
} else {
currentBlock.val.push({ type: 'token', val: token });
}
}
function removeDataFromBlocks(block) {
return { type: block.type, val: block.type === 'block' ? block.val.map(removeDataFromBlocks) : block.val }
}
return removeDataFromBlocks(mainBlock);
}
function tokenise(text) {
const regex = /\!\([\d\w/\. \$]+\)|\!\//g;
let m;
const commands = [];
do {
m = regex.exec(text);
if (m) commands.push({
text: m[0],
index: m.index,
type: 'command',
});
} while (m);
const tokens = [];
let textRemaining = text;
let index = 0;
for (let i = 0; i < commands.length; i++) {
const arr = textRemaining.split(commands[i].text);
tokens.push({
text: arr[0],
index,
type: 'text',
});
tokens.push(commands[i])
index += arr[0].length + commands[i].text.length,
arr.splice(0, 1);
textRemaining = arr.join(commands[i].text);
}
tokens.push({
text: textRemaining,
index,
type: 'text',
});
return commandInfo(tokens);
}
function commandInfo(tokens) {
return tokens.map(token => token.type === 'command' ? parseCommandToken(token) : token);
}
function parseCommandToken(command) {
const commandTypes = {
for: /\!\(for ([\w\d])+ in ([\w.\d $])+\)/g,
endBlock: /\!\//g,
if: /\!\(if ([\w.\d $])+\)/g,
else: /\!\(else\)/g,
ins: /\!\(([/\w.\d $])+\)/g,
}
let commandType;
for (let k in commandTypes) {
if (commandTypes[k].test(command.text)) {
commandType = k;
break;
}
}
let commandArgs = [];
const commandText = command.text.substring(2, command.text.length - 1);
switch (commandType) {
case 'for':
let parts = commandText.split(' ');
commandArgs = [parts[1], parts.slice(3).join(" ")];
break;
case 'if':
commandArgs = [commandText.split(' ').slice(1).join(" ")];
break;
case 'ins':
commandArgs = [commandText];
break;
default:
break;
}
return { ...command, commandType, commandArgs }
}
module.exports = fastPlate;
const fs = require('fs').promises;
const path = require('path');
const fastplate = require("./fastplate");
async function renderPage(templatePath, options = {}) {
const filePath = templatePath.split('/');
const templateName = filePath[filePath.length - 1] + '.html';
const folders = filePath.slice(0, filePath.length - 1);
const html = await fs.readFile(path.join(__dirname, '..', 'views', ...folders, templateName), 'utf-8');
const context = { ...options, load: (p, ...args) => renderPage(path.join(...folders, p), { args, context: options }) };
return fastplate(html, context);
}
module.exports = renderPage;
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment