scetch
Version:
a templating engine thats different. probably.
474 lines (415 loc) • 19.3 kB
JavaScript
// TODO: There's a bug with loops where if the condition fails the first time, we still print the data! fix this!
// A fix might be to document this behaviour with a fix (wrap in an if block) and patch in scetch 2.0.0 (major change)?
const vm = require('vm'); // change this to vm2!!
const path = require('path');
const fs = require('fs').promises;
// TODO: Turn this into a fs.promises.readFile and .then/await the result
const scetchInjectScript = require("./util/scetchInjectScript");
const scetchBindingScript = require("./util/scetchBindingScript");
let scetchDefaults = {
root: path.join(__dirname, 'views'),
ext: ".sce",
nonceName: "nonce",
alwaysScetchInject: false,
};
let scetchOptions = {};
// Escape polyfill because that's how we replace all.
if(!RegExp.escape) {
RegExp.escape = function (s) {
return String(s).replace(/[\\^$*+?.()|[\]{}]/g, '\\$&');
};
}
// Matchall polyfill - this is how we handle Node <12
if(!String.prototype.matchAll) {
String.prototype.matchAll = function (rx) {
if(typeof rx === "string") rx = new RegExp(rx, "g");
rx = new RegExp(rx);
let cap = [];
let all = [];
while((cap = rx.exec(this)) !== null) all.push(cap);
return all;
};
}
if(!String.prototype.replaceAll) {
String.prototype.replaceAll = function (search, replacement) {
if(search instanceof RegExp) {
search = new Regexp(search.source, search.flags + (search.global ? "" : "g"));
return this.replace(search, replacement);
}
return this.split(search).join(replacement);
};
}
function runInContext(script, context) {
try {
return vm.runInContext(script, context);
} catch(e) {
//console.error(e)
}
return false;
}
function runInNewContext(script, context) {
try {
return vm.runInNewContext(script, context);
} catch(e) {
//console.error(e);
}
return false;
}
function recurseGetVariable(varName, variables) {
let dotNot = typeof varName === "string" ? varName.split('.') : Array.isArray(varName) ? varName : undefined;
if(dotNot === undefined) return undefined;
let variable = variables;
for(let d of dotNot) {
if(typeof variable === "undefined") break;
variable = variable[d];
}
return variable;
}
function engine(filePath, variables, callback) {
if(!path.isAbsolute(filePath)) filePath = path.join(scetchOptions.root, filePath);
if(!filePath.endsWith('.sce')) filePath += '.sce';
let p = fs.readFile(filePath)
.then(data => data.toString())
.then(data => processData.call(this, data, variables))
.then(data => applyVariables.call(this, data, variables))
.then(data => applyDataBindings.call(this, data, variables))
.then(data => applyComponentLoadScripts.call(this, data, variables))
.then(data => {
if(!callback) return data;
callback(null, data);
}).catch(err => {
if(!callback) return err;
try {
callback(err);
} catch(catchErr) {
console.error("FATAL?");
console.error(catchErr);
}
});
if(!callback) return p;
}
// TODO: Refactor this to only handle the top level collecting of scetch fragments
// Once we have the complete scetch file, we should then operate on it
// Operating on it means: apply variables and logic. This means partials, load and injections
// should happen beforehand.
// The final scetch load script should be added to the end of this big blob of now-HTML code.
// We need to track the scetch'd file in an object so we can recurse with logic and variables,
// and then apply the final scetch load script to the end of the file.
// But then again, maybe we just did it?
async function processData(data, variables, noLogic) {
if(typeof this.root === "string") scetchOptions.root = this.root;
if(typeof this.ext === "string") scetchOptions.ext = this.ext;
return Promise.resolve(data)
.then(data => applyPartials(data, variables))
.then(data => applyComponentInjections(data, variables))
.then(data => applyVariables(data, variables))
.then(data => noLogic ? data : applyLogic(data, variables));
}
async function applyPartials(data, variables) {
const rx = /\[\[i= *(.*?) *\]\]/gi;
let matchBoxes = [...data.matchAll(rx)];
if(!matchBoxes || !matchBoxes.length) return data;
matchBoxes = matchBoxes.filter((v, i, s) => s.indexOf(v) === i);
let root = scetchOptions.root;
let ext = scetchOptions.ext;
for(let box of matchBoxes) {
try {
let partial = (await fs.readFile(path.join(root, box[1] + ext))).toString();
partial = await processData(partial, variables);
data = data.replaceAll(box[0], partial);
} catch(e) {
console.error(e);
continue;
}
}
return data;
}
async function applyVariables(data, variables) {
if(typeof variables === "undefined" || Object.getOwnPropertyNames(variables).length === 0) return data;
const rx = /\[\[[^\[=]*? *([^\[\]\s]+?) *\]\]/gi;
let matchBoxes = [...data.matchAll(rx)];
if(!matchBoxes || !matchBoxes.length) return data;
matchBoxes = matchBoxes.filter((v, i, s) => s.indexOf(v) === i); // TODO: Fix this filter
for(let box of matchBoxes) {
let variable = recurseGetVariable(box[1], variables);
// TODO: Stringify 'variable' correctly.
if(variable !== undefined) data = data.replace(new RegExp(RegExp.escape(box[0]), "g"), variable);
}
return data;
}
async function applyComponentLoadScripts(data, variables) {
const rx = /\[\[l= *(.+?) *\]\]/gi;
let matchBoxes = [...data.matchAll(rx)];
if((!matchBoxes || !matchBoxes.length) && !scetchOptions.alwaysScetchInject) return data;
matchBoxes = matchBoxes.filter((v, i, s) => s.indexOf(v) === i);
let root = scetchOptions.root;
let ext = scetchOptions.ext;
let script = `<script nonce="${variables[scetchOptions.nonceName]}">(${scetchInjectScript})();(()=>{`;
for(let box of matchBoxes) {
try {
let p = path.join(root, box[1] + ext);
let componentName = path.basename(p, ext);
let component = (await fs.readFile(p)).toString();
script += `scetch["${componentName}"] = \`${component}\`;\n`;
data = data.replace(new RegExp(RegExp.escape(box[0]), "g"), ""); // remove all component references
} catch(e) {
console.error(e);
continue;
}
}
script += `})();</script>`;
let insert = data.indexOf("</body>");
data = data.substr(0, insert) + script + data.substr(insert);
return data;
}
// TODO: dot ops don't work inside components if you don't name it the exact same as inside the component.
// TODO: Maybe use recurseGetVariable?
// [[c= component || comp=c ]]
// [[ comp.hello ]] <-- This won't work
// [[ [[c]].hello ]] <-- This won't work either
async function applyComponentInjections(data, variables) {
const rx = /\[\[c= *([^ ]+?)(?: *\|\| *(.+?))? *\]\]/gi;
const rxV = /(\w+)=("[^"\\]*(?:\\.[^"\\]*)*"|(?:\w+\.*)+)/gi;
let matchBoxes = [...data.matchAll(rx)];
if(!matchBoxes || !matchBoxes.length) return data;
// get opts for all matchboxes
let root = scetchOptions.root;
let ext = scetchOptions.ext;
for(let box of matchBoxes) {
let matches = (box[2] || "").match(rxV) || [];
matches = matches.map((el) => el.split("="));
let options = {};
for(let opt of matches) {
if(opt[1].startsWith(`"`)) opt[1] = opt[1].substring(1, opt[1].length - 1).replace(/\\"/gi, "\"");
else opt[1] = variables[opt[1]] || `[[ ${opt[1]} ]]`;
options[opt[0]] = opt[1];
}
try {
let component = (await fs.readFile(path.join(root, box[1] + ext))).toString();
component = await processData(component, options);
data = data.replace(new RegExp(RegExp.escape(box[0]), "g"), component);
} catch(e) {
continue;
}
}
return data;
}
async function applyDataBindings(data, variables) {
const rx = /\[\[b= *(\w+?) +("[^"\\]*(?:\\.[^"\\]*)*"|(?:\w+\.*)+) +("[^"\\]*(?:\\.[^"\\]*)*"|(?:\w+\.*)+) +("[^"\\]*(?:\\.[^"\\]*)*"|(?:\w+\.*)+)? *\]\]/gi;
let matchBoxes = [...data.matchAll(rx)];
if(!matchBoxes || !matchBoxes.length) return data;
let script = `<script nonce="${variables[scetchOptions.nonceName]}">(${scetchBindingScript})();(()=>{`;
for(let box of matchBoxes) {
try {
let varName = box[1]; // the variable name
let element = box[2]; // the element to target
let attribute = box[3]; // the attribute to bind to
let defaultValue = box[4] ?? undefined; // the defaut value to bind (optional)
if(element.startsWith(`"`)) element = element.substring(1, element.length - 1).replace(/\\"/gi, "\"");
else element = variables[element];
if(attribute.startsWith(`"`)) attribute = attribute.substring(1, attribute.length - 1).replace(/\\"/gi, "\"");
else attribute = variables[attribute];
if(!!defaultValue) {
if(defaultValue.startsWith(`"`)) defaultValue = defaultValue.substring(1, defaultValue.length - 1).replace(/\\"/gi, "\"");
else defaultValue = variables[defaultValue];
}
script += `scetch.bind("${varName}", "${element}", "${attribute}", ${JSON.stringify(defaultValue)});\n`;
data = data.replace(new RegExp(RegExp.escape(box[0]), "g"), ""); // remove all binding references for this one
} catch(e) {
console.error(e);
continue;
}
}
script += `})();</script>`;
let insert = data.indexOf("</body>");
data = data.substr(0, insert) + script + data.substr(insert);
return data;
}
async function applyLogic(data, variables) {
// The name is quite suiting... 🤣🙃
const endConditional = /\[\[\?==\]\]/i;
const endConditionalString = "[[?==]]";
const ifOpen = /\[\[\?= *([^\s=].*?) *\]\]/gi;
const elseIf = /\[\[3= *(.*?) *\]\]/gi;
const numberLoop = /\[\[f= *(\w+?) *(\d+):(?:(\d+):)?(\d+) *\]\]/gi;
const eachLoop = /\[\[e= *(\w+) *in *(\S+) *\]\]/gi;
const whileLoop = /\[\[w= *(\S.*?) *\]\]/gi;
// instead of splitting on each line, we split on what *COULD BE* a closing scetch tag.
// This allows us to break single line scetch conditionals and operate on them as if they were multiline.
// data = data.split("\n");
data = data.split("[[").map((e, i, a) => i === 0 ? e : "[[" + e);
// let buffer = []; // the buffer is used to store manipulated lines (ie, opening loop lines). this means we can loop back to them once they've been processed without re-processing the loop itself
let schema = (type, line, meta, vars) => {
return {
"type": type, // if, for, each, while
"line": line || 0, // the line of the start (not reqd always)
"meta": meta || {}, // any additional info for this type (not reqd always)
"vars": vars || {},
"output": true,
"buffer": []
};
};
// TODO: Another output fix would be depth.output where it'll traverse the
// stack finding an "output=flase" and then saying no to output if
// that's the case.
let depth = [];
depth.last = () => depth[depth.length - 1];
depth.getVars = () => {
let o = {};
for(let d of depth) {
o = Object.assign(o, d.vars);
}
return o;
};
let allVars = () => Object.assign({}, variables, depth.getVars());
let ret = [];
let safeEval;
// TODO: Make the scoping of end conditions more correct!
// TODO: Read a buffer instead of per line -- this'll allow one-liners -- FIXED?
// More appropriate names would be chunkNo, and chunk instead of lines
for(let lineNo = 0; lineNo < data.length; lineNo++) {
let line = depth.last()?.buffer[lineNo] ?? data[lineNo];
if(depth.length > 0 && line.includes(endConditionalString)) {
// if relooping, continue so we don't end the block
// this means if we're popping from the stack, then we break so we can continue reading the file
switch(depth.last().type) {
case "if":
depth.pop();
break;
case "for":
depth.last().meta.val += depth.last().meta.skip;
if(depth.last().meta.val >= depth.last().meta.stop) {
//break loop
depth.pop();
break;
} else {
depth.last().vars[depth.last().meta.varName] = depth.last().meta.val;
lineNo = depth.last().line - 1; // line-1 so we can process the start of the loop that's in the buffer
}
continue;
case "each":
depth.last().meta.idx++;
if(depth.last().meta.idx >= depth.last().meta.length) {
depth.pop();
break;
} else {
depth.last().vars[depth.last().meta.varName] = depth.last().meta.collection[depth.last().meta.idx];
lineNo = depth.last().line - 1;
}
continue;
case "while":
let safeEval = runInContext(depth.last().meta.condition, depth.last().meta.context);
// let safeEval = depth.last().meta.condition.runInContext(depth.last().meta.context);
if(safeEval) {
lineNo = depth.last().line - 1;
} else {
depth.pop();
break;
}
continue;
}
line = line.substring(endConditionalString.length);
}
// Opening If
let matchBoxes = [...line.matchAll(ifOpen)];
if(!!matchBoxes && matchBoxes.length) {
depth.push(schema("if", lineNo, {
ran: false
}));
safeEval = runInNewContext(matchBoxes[0][1], allVars());
// safeEval = vm.runInNewContext(matchBoxes[0][1], allVars()); // lol not so safe...
depth.last().meta.ran = depth.last().output = safeEval;
line = line.substring(matchBoxes[0][0].length);
}
// Else If, Else
matchBoxes = [...line.matchAll(elseIf)];
if(!!matchBoxes && matchBoxes.length && depth.last().type === "if") {
if(depth.last().output || depth.last().meta.ran) depth.last().output = false;
else {
// else if or just else
if(matchBoxes[0][1] != "") safeEval = runInNewContext(matchBoxes[0][1], allVars());
// if (matchBoxes[0][1] != "") safeEval = vm.runInNewContext(matchBoxes[0][1], allVars());
else safeEval = true;
depth.last().meta.ran = depth.last().output = safeEval;
}
line = line.substring(matchBoxes[0][0].length);
}
// For each number
matchBoxes = [...line.matchAll(numberLoop)];
if(!!matchBoxes && matchBoxes.length) {
let out = true;
let d = {
varName: matchBoxes[0][1],
start: parseInt(matchBoxes[0][2]),
val: parseInt(matchBoxes[0][2]),
skip: parseInt(matchBoxes[0][3] || 1),
stop: parseInt(matchBoxes[0][4])
};
if(d.start === d.stop) out = false; // 0 loop
if(Math.sign(d.stop - d.start) != Math.sign(d.skip)) continue; // iterating wrong way! -- TODO: Handle this better
let v = {
[d.varName]: d.val
};
if(depth.length > 0) out = out && depth.last().output;
depth.push(schema("for", lineNo, d, v));
depth.last().output = out;
line = line.substring(matchBoxes[0][0].length);
depth.last().buffer[lineNo] = line;
}
// For each OBJECT
matchBoxes = [...line.matchAll(eachLoop)];
if(!!matchBoxes && matchBoxes.length) {
let out = true;
let d = {
varName: matchBoxes[0][1],
idx: 0,
collection: recurseGetVariable(matchBoxes[0][2], allVars()),
};
d.length = (d.collection || []).length;
if(d.length === 0) out = false;
let v = {};
if(d.collection) v[d.varName] = d.collection[d.idx];
if(depth.length > 0) out = out && depth.last().output;
depth.push(schema("each", lineNo, d, v));
depth.last().output = out;
line = line.substring(matchBoxes[0][0].length);
depth.last().buffer[lineNo] = line;
}
// While Loop
matchBoxes = [...line.matchAll(whileLoop)];
if(!!matchBoxes && matchBoxes.length) {
let out = true;
if(depth.length > 0) out = out && depth.last().output;
depth.push(schema("while", lineNo, {
condition: matchBoxes[0][1],
context: vm.createContext(allVars()),
looping: false
}));
let safeEval = runInContext(depth.last().meta.condition, dept.last().meta.context);
// let safeEval = depth.last().condition.runInContext(depth.last().meta.context);
depth.last().meta.looping = depth.last().output = safeEval && out;
line = line.substring(matchBoxes[0][0].length);
depth.last().buffer[lineNo] = line;
}
if(depth.length == 0 || depth.last().output) {
ret.push(await processData(line, allVars(), true));
}
}
if(depth.length > 0) console.error("depth wasn't emptied! This shouldn't happen! Contents: " + depth.map(d => d.type).join(", "));
data = ret.join("");
return data;
}
module.exports = (opts) => {
scetchOptions = Object.assign({}, scetchDefaults, opts || {});
return module.exports;
};
module.exports.override = (key, value) => {
if(typeof key === 'object') {
for(const k in key) {
if(key.hasOwnProperty(k)) module.exports.override(k, key[k]);
}
} else if(typeof key === 'string' && typeof value !== 'undefined') {
scetchOptions[key] = value;
}
};
module.exports.engine = engine;