/* jshint evil: true */ var fs = require('fs'); var path = require('path'); var _template = require('lodash.template'); var esprima = require('esprima'); // disable ES6 delimiters var _templateSettings = {interpolate: /<%=([\s\S]+?)%>/g}; // Functions used in templates. var tools = {}; var read = tools.read = filename => fs.readFileSync(filename, 'utf8').replace(/\r\n/g, '\n'); var readJSON = tools.readJSON = filename => JSON.parse(read(filename)); tools.readBase64 = filename => fs.readFileSync(filename).toString('base64'); tools.readHTML = function(filename) { var text = read(filename).replace(/^ +/gm, '').replace(/\r?\n/g, ''); text = _template(text, _templateSettings)(pkg); // package.json data only; no recursive imports return tools.html(text); }; tools.multiline = function(text) { return text.replace(/\n+/g, '\n').split(/^/m).map(JSON.stringify).join('+').replace(/"\+"/g, '\\\n'); }; // Convert JSONify-able object to Javascript expression. var constExpression = data => JSON.stringify(data).replace(/`/g, '\\`'); function TextStream(text) { this.text = text; } TextStream.prototype.eat = function(regexp) { var match = regexp.exec(this.text); if (match && match.index === 0) { this.text = this.text.slice(match[0].length); } return match; }; function parseHTMLTemplate(stream, context) { var template = stream.text; // text from beginning, for error messages var expression = new HTMLExpression(context); var match; try { while (stream.text) { // Literal HTML if ((match = stream.eat( // characters not indicating start or end of placeholder, using backslash as escape /^(?:[^\\{}]|\\.)+(?!{)/ ))) { var unescaped = match[0].replace(/\\(.)/g, '$1'); expression.addLiteral(unescaped); // Placeholder } else if ((match = stream.eat( // symbol identifying placeholder type and first argument (enclosed by {}) // backtick not allowed in arguments as it can end embedded JS in Coffeescript /^([^}]){([^}`]*)}/ ))) { var type = match[1]; var args = [match[2]]; if (type === '?') { // conditional expression can take up to two subtemplate arguments for (var i = 0; i < 2 && stream.eat(/^{/); i++) { var subtemplate = parseHTMLTemplate(stream, context); args.push(subtemplate); if (!stream.eat(/^}/)) { throw new Error(`Unexpected characters in subtemplate (${stream.text})`); } } } expression.addPlaceholder(new Placeholder(type, args)); // No match: end of subtemplate (} next) or error } else { break; } } return expression.build(); } catch(err) { throw new Error(`${err.message}: ${template}`); } } function HTMLExpression(context) { this.parts = []; this.startContext = this.endContext = (context || ''); } HTMLExpression.prototype.addLiteral = function(text) { this.parts.push(constExpression(text)); this.endContext = ( this.endContext .replace(/(=['"])[^'"<>]*/g, '$1') // remove values from quoted attributes (no '"<> allowed) .replace(/(<\w+)( [\w-]+((?=[ >])|=''|=""))*/g, '$1') // remove attributes from tags .replace(/^([^'"<>]+|<\/?\w+>)*/, '') // remove text (no '"<> allowed) and tags ); }; HTMLExpression.prototype.addPlaceholder = function(placeholder) { if (!placeholder.allowed(this.endContext)) { throw new Error(`Illegal insertion of placeholder (type ${placeholder.type}) into HTML template (at ${this.endContext})`); } this.parts.push(placeholder.build()); }; HTMLExpression.prototype.build = function() { if (this.startContext !== this.endContext) { throw new Error(`HTML template is ill-formed (at ${this.endContext})`); } return (this.parts.length === 0 ? '""' : this.parts.join(' + ')); }; function Placeholder(type, args) { this.type = type; this.args = args; } Placeholder.prototype.allowed = function(context) { switch(this.type) { case '$': // escaped text allowed outside tags or in quoted attributes return (context === '' || /\=['"]$/.test(context)); case '&': case '@': // contents of one/many HTML element or template allowed outside tags only return (context === ''); case '?': // conditionals allowed anywhere so long as their contents don't change context (checked by HTMLExpression.prototype.build) return true; } throw new Error(`Unrecognized placeholder type (${this.type})`); }; Placeholder.prototype.build = function() { // first argument is always JS expression; validate it so we don't accidentally break out of placeholders var expr = this.args[0]; var ast; try { ast = esprima.parse(expr); } catch (err) { throw new Error(`Invalid JavaScript in template (${expr})`); } if (!(ast.type === 'Program' && ast.body.length == 1 && ast.body[0].type === 'ExpressionStatement')) { throw new Error(`JavaScript in template is not an expression (${expr})`); } switch(this.type) { case '$': return `E(${expr})`; // $ : escaped text case '&': return `(${expr}).innerHTML`; // & : contents of HTML element or template (of form {innerHTML: "safeHTML"}) case '@': return `E.cat(${expr})`; // @ : contents of array of HTML elements or templates (see src/General/Globals.coffee for E.cat) case '?': return `((${expr}) ? ${this.args[1] || '""'} : ${this.args[2] || '""'})`; // ? : conditional expression } throw new Error(`Unrecognized placeholder type (${this.type})`); }; // HTML template generator with placeholders of forms ${}, &{}, @{}, and ?{}{}{} (see Placeholder.prototype.build) // that checks safety of generated expressions at compile time. tools.html = function(template) { var stream = new TextStream(template); var output = parseHTMLTemplate(stream); if (stream.text) { throw new Error(`Unexpected characters in template (${stream.text}): ${template}`); } return `{innerHTML: ${output}}`; }; function includesDir(templateName) { var dir = path.dirname(templateName); var subdir = path.basename(templateName).replace(/\.[^.]+$/, ''); if (fs.readdirSync(dir).indexOf(subdir) >= 0) { return path.join(dir, subdir); } else { return dir; } } function resolvePath(includeName, templateName) { var dir; if (includeName[0] === '/') { dir = process.cwd(); } else { dir = includesDir(templateName); } return path.join(dir, includeName); } function wrapTool(tool, templateName) { return function(includeName) { return tool(resolvePath(includeName, templateName)); }; } function loadModules(templateName) { var dir = includesDir(templateName); var moduleNames = fs.readdirSync(dir).filter(f => /\.inc$/.test(f)); var modules = {}; for (var name of moduleNames) { var code = read(path.join(dir, name)); modules[name.replace(/\.inc$/, '')] = new Function(code)(); } return modules; } // Import variables from package.json. var pkg = readJSON('package.json'); function interpolate(text, data, filename) { var context = {}, key; for (key in tools) { context[key] = /^read/.test(key) ? wrapTool(tools[key], filename) : tools[key]; } for (key in pkg) { context[key] = pkg[key]; } if (data) { for (key in data) { context[key] = data[key]; } } context.files = fs.readdirSync(includesDir(filename)); context.require = loadModules(filename); return _template(text, _templateSettings)(context); } module.exports = interpolate; if (require.main === module) { (function() { // Take variables from command line. var data = {}; for (var i = 4; i < process.argv.length; i++) { var m = process.argv[i].match(/(.*?)=(.*)/); data[m[1]] = m[2]; } var text = read(process.argv[2]); text = interpolate(text, data, process.argv[2]); fs.writeFileSync(process.argv[3], text); })(); }