Skip to content

Instantly share code, notes, and snippets.

@jjantschulev
Last active December 1, 2019 23:51
Show Gist options
  • Save jjantschulev/e557e13e766860dde582671c27483a79 to your computer and use it in GitHub Desktop.
Save jjantschulev/e557e13e766860dde582671c27483a79 to your computer and use it in GitHub Desktop.
Simple HTML Templating language parser. Loop support.
const path = require("path");
const fs = require("fs");
const { promisify } = require("util");
const fsReadFile = promisify(fs.readFile);
const LOADED_FILES = {};
const COMMANDS = ["include", "insert", "loop", "endloop", "if", "endif"];
async function readFile(filename, options) {
if (process.env.ENV === "dev") {
return await fsReadFile(filename, options);
} else {
if (LOADED_FILES[filename]) {
return LOADED_FILES[filename];
} else {
const file = await fsReadFile(filename, options);
LOADED_FILES[filename] = file;
return file;
}
}
}
const options = {
screenPath: path.join(__dirname, "../views/screens"),
componentPath: path.join(__dirname, "../views/components"),
emailPath: path.join(__dirname, "../views/email")
};
async function renderPage(res, pageName, data) {
try {
const html = await renderPageToHTML(pageName, data);
res.send(html);
} catch (error) {
res.send(renderError(error.message));
}
}
async function renderPageToHTML(pageName, data, isEmail) {
const filename = `${
isEmail ? options.emailPath : options.screenPath
}/${pageName}.html`;
const html = await renderComponent(filename, data);
return html;
}
async function renderComponent(filename, data) {
// Parses the html file into commands and html
const rawHtml = await readFile(filename, "utf-8");
const lines = rawHtml.split(/([@;])+/);
let resultHtml = "";
let context = {
command: false,
loops: [],
lineIndex: 0,
ifStack: []
};
const write = html => {
let writeOutput = true;
for (let i = 0; i < context.ifStack.length; i++) {
if (!context.ifStack[i]) {
writeOutput = false;
break;
}
}
if (writeOutput) {
resultHtml += html;
}
};
for (
context.lineIndex = 0;
context.lineIndex < lines.length;
context.lineIndex++
) {
if (lines[context.lineIndex] === "@") {
const prevLine = lines[context.lineIndex - 1];
if (prevLine[prevLine.length - 1] === "\\") {
resultHtml = resultHtml.slice(0, -1) + "@";
} else {
context.command = true;
}
continue;
}
if (lines[context.lineIndex] === ";") {
if (context.command === true) {
context.command = false;
continue;
}
}
if (context.command) {
const [command, args] = parseCommand(lines[context.lineIndex]);
const { html, context: newContext } = await execCommand(
command,
args,
data || {},
context
);
context = { ...context, ...(newContext || {}) };
write(html);
continue;
}
write(lines[context.lineIndex]);
}
return resultHtml;
}
async function execCommand(command, args, data, context) {
if (command === "include") {
args = args.map(a => {
if (a[0] === "$") {
return getObjectValue(data, a.slice(1), context);
} else {
return a;
}
});
const componentName = args[0];
const filename = `${options.componentPath}/${componentName}.html`;
const html = await renderComponent(filename, { args: args.slice(1) });
return { html };
}
if (command === "insert") {
const loopValues = context.loops
.filter(l => l.indexName === args[0])
.map(l => l.index);
if (loopValues.length > 0) {
return { html: loopValues[0].toString() };
}
if (!data) return { html: renderError("Data object is undefined") };
let value = getObjectValue(data, args[0], context);
const defaultValue = args[1];
if (!value) value = defaultValue || "";
return { html: value.toString() };
}
if (command === "loop") {
const array = getObjectValue(data, args[1], context);
return {
html: "",
context: {
loops: [
...context.loops,
{
index: 0,
indexName: args[0],
startLineIndex: context.lineIndex,
length: array.length
}
]
}
};
}
if (command === "endloop") {
const currentLoopIndex = context.loops.length - 1;
if (currentLoopIndex === -1) throw Error("Unexpected @endloop");
const loop = context.loops[currentLoopIndex];
loop.index++;
const loopFinished = loop.index === loop.length;
if (loopFinished) {
context.loops.splice(currentLoopIndex, 1);
return {
html: ""
};
}
return {
html: "",
context: {
command: false,
lineIndex: loop.startLineIndex + 1
}
};
}
if (command === "if") {
const parts = args[0].split("=");
const dataValue = getObjectValue(data, parts[0], context);
let value;
if (parts.length === 2) {
value = dataValue == evaluateLiteral(parts[1]);
} else if (parts.length === 1) {
value = dataValue;
}
const invert = args[1] === "not";
const bool = invert ? !value : value;
return { html: "", context: { ifStack: [...context.ifStack, bool] } };
}
if (command === "endif") {
return {
html: "",
context: {
ifStack: context.ifStack.slice(0, -1)
}
};
}
return { html: "", context };
}
function parseObjectPath(path, context) {
const objectParts = path.split(".").map(p =>
p
.split(/[\[\]]+/)
.filter(p => !!p)
.map((p, i) => {
if (i === 0) {
return p;
}
if (context.loops.length > 0) {
for (let j = 0; j < context.loops.length; j++) {
if (p === context.loops[j].indexName) {
return context.loops[j].index;
}
}
}
return parseInt(p, 10);
})
);
const objectPath = [].concat(...objectParts);
return objectPath;
}
function getObjectValue(object, path, context) {
const parsedPath = parseObjectPath(path, context);
let value = object;
for (let i = 0; i < parsedPath.length; i++) {
if (!value)
throw Error(
`Invalid object path: <code>${parsedPath.join(
"."
)}</code><br><br> JSON:<br><br> <pre><code>${JSON.stringify(
object,
null,
4
)}</code></pre>`
);
value = value[parsedPath[i]];
}
return value;
}
function parseCommand(statement) {
const parts = statement.split(" ");
const c = parts.shift();
if (COMMANDS.indexOf(c) === -1)
throw Error(`Invalid command: <pre>${c}</pre>`);
const command = c;
return [command, parts];
}
function renderError(message) {
if (process.env.ENV === "prod") {
console.error("ERROR: ", message);
return "An error has occured. Please try refreshing the page.";
}
return `<span style="font-weight: bold; color: red">Error: ${message}</span>`;
}
function evaluateLiteral(literal) {
if (literal === "true") return true;
if (literal === "false") return false;
if (!isNaN(parseInt(literal))) return parseInt(literal);
return literal;
}
module.exports = { renderPage, renderPageToHTML };
@J-Cake
Copy link

J-Cake commented Nov 19, 2019

Supreme effort. But you just wait till I release React 2.0

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment