Last active
December 1, 2019 23:51
-
-
Save jjantschulev/e557e13e766860dde582671c27483a79 to your computer and use it in GitHub Desktop.
Simple HTML Templating language parser. Loop support.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 }; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Supreme effort. But you just wait till I release React 2.0