From 544532730204c236823ef42c368dc4dccdfcc8e5 Mon Sep 17 00:00:00 2001 From: ccd0 Date: Mon, 11 Apr 2016 23:51:11 -0700 Subject: [PATCH] Rewrite template handler in JS, add much-needed comments and try to separate concerns. --- Makefile | 11 +-- tools/template.js | 179 +++++++++++++++++++++++++++++++++++++++++ tools/templates.coffee | 87 -------------------- 3 files changed, 183 insertions(+), 94 deletions(-) create mode 100644 tools/template.js delete mode 100644 tools/templates.coffee diff --git a/Makefile b/Makefile index 898c176be..0d9a8f4d8 100644 --- a/Makefile +++ b/Makefile @@ -14,11 +14,8 @@ endif coffee := $(BIN)coffee -c --no-header coffee_deps := node_modules/coffee-script/package.json -template := $(BIN)coffee tools/templates.coffee -template_deps := \ - package.json version.json \ - tools/templates.coffee \ - node_modules/coffee-script/package.json node_modules/lodash/package.json +template := node tools/template.js +template_deps := package.json version.json tools/template.js node_modules/lodash/package.json cat := node tools/cat.js cat_deps := tools/cat.js jshint_deps := .jshintrc node_modules/jshint/package.json @@ -179,8 +176,8 @@ builds/% : testbuilds/% $(jshint) | builds test.html : README.md template.jst tools/markdown.js node_modules/marked/package.json node_modules/lodash/package.json node tools/markdown.js -.jshintrc : tools/templates.coffee src/meta/jshint.json $(template_deps) - $(template) src/meta/jshint.json .jshintrc +.jshintrc : src/meta/jshint.json $(template_deps) + $(template) $< .jshintrc .events/jshint.% : tmp/%.js $(jshint_deps) | .events $(BIN)jshint $< diff --git a/tools/template.js b/tools/template.js new file mode 100644 index 000000000..e5bffaf8a --- /dev/null +++ b/tools/template.js @@ -0,0 +1,179 @@ +var fs = require('fs'); +var _ = require('lodash'); + +// disable ES6 delimiters +_.templateSettings.interpolate = /<%=([\s\S]+?)%>/g; + +var pkg = {}; + +var read = pkg.read = filename => fs.readFileSync(filename, 'utf8').replace(/\r\n/g, '\n'); +var readJSON = pkg.readJSON = filename => JSON.parse(read(filename)); +pkg.readBase64 = filename => fs.readFileSync(filename).toString('base64'); +pkg.ls = pathname => fs.readdirSync(pathname); + +_.assign(pkg, readJSON('package.json')); +_.assign(pkg.meta, readJSON('version.json')); + +// Convert JSON object to Coffeescript expression (via embedded JS). +var constExpression = data => '`' + JSON.stringify(data).replace(/`/g, '\\`') + '`'; + +pkg.importCSS = function() { + var text = Array.prototype.slice.call(arguments).map(name => read(`src/css/${name}.css`)).join(''); + text = _.template(text)(pkg); + return text.trim().replace(/\n+/g, '\n').split(/^/m).map(JSON.stringify).join(' +\n').replace(/`/g, '\\`'); +}; + +pkg.importHTML = function(filename) { + var text = read(`src/${filename}.html`).replace(/^ +/gm, '').replace(/\r?\n/g, ''); + text = _.template(text)(pkg); + return pkg.html(text); +}; + +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; add backticks for embedding it in Coffeescript + var expr = '`'+this.args[0]+'`'; + 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 `(if ${expr} then ${this.args[1] || '""'} else ${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. +pkg.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})`; +}; + +pkg.assert = function(statement) { + if (!pkg.tests_enabled) return ''; + return `throw new Error 'Assertion failed: ' + ${constExpression(statement)} unless ${statement}`; +}; + +for (var i = 4; i < process.argv.length; i++) { + var m = process.argv[i].match(/(.*?)=(.*)/); + pkg[m[1]] = m[2]; +} + +var text = read(process.argv[2]); +text = _.template(text)(pkg); +fs.writeFileSync(process.argv[3], text); diff --git a/tools/templates.coffee b/tools/templates.coffee deleted file mode 100644 index e777e3e75..000000000 --- a/tools/templates.coffee +++ /dev/null @@ -1,87 +0,0 @@ -fs = require 'fs' -_ = require 'lodash' - -# disable ES6 delimiters -_.templateSettings.interpolate = /<%=([\s\S]+?)%>/g - -read = (filename) -> fs.readFileSync(filename, 'utf8').replace(/\r\n/g, '\n') -readJSON = (filename) -> JSON.parse read filename -readBase64 = (filename) -> fs.readFileSync(filename).toString('base64') - -pkg = readJSON 'package.json' -_.assign pkg.meta, readJSON 'version.json' - -json = (data) -> - "`#{JSON.stringify(data).replace(/`/g, '\\`')}`" - -importCSS = (filenames...) -> - text = filenames.map((name) -> read "src/css/#{name}.css").join('') - text = _.template(text)(pkg) - text.trim().replace(/\n+/g, '\n').split(/^/m).map(JSON.stringify).join(' +\n').replace(/`/g, '\\`') - -importHTML = (filename) -> - text = read("src/#{filename}.html").replace(/^ +/gm, '').replace(/\r?\n/g, '') - text = _.template(text)(pkg) - html text - -parseTemplate = (template, context='') -> - context0 = context - parts = [] - text = template - while text - if part = text.match /^(?:[^{}\\]|\\.)+(?!{)/ - text = text[part[0].length..] - unescaped = part[0].replace /\\(.)/g, '$1' - context = (context + unescaped) - .replace(/(=['"])[^'"<>]*/g, '$1') - .replace(/(<\w+)( [\w-]+((?=[ >])|=''|=""))*/g, '$1') - .replace(/^([^'"<>]+|<\/?\w+>)*/, '') - parts.push json unescaped - else if part = text.match /^([^}]){([^}`]*)}/ - text = text[part[0].length..] - unless context is '' or (part[1] is '$' and /\=['"]$/.test context) or part[1] is '?' - throw new Error "Illegal insertion into HTML template (at #{context}): #{template}" - parts.push switch part[1] - when '$' then "E(`#{part[2]}`)" - when '&' then "`#{part[2]}`.innerHTML" - when '@' then "E.cat(`#{part[2]}`)" - when '?' - args = ['""', '""'] - for i in [0...2] - break if text[0] isnt '{' - text = text[1..] - [args[i], text] = parseTemplate text, context - if text[0] isnt '}' - throw new Error "Unexpected characters in subtemplate (#{text}): #{template}" - text = text[1..] - "(if `#{part[2]}` then #{args[0]} else #{args[1]})" - else - throw new Error "Unrecognized substitution operator (#{part[1]}): #{template}" - else - break - if context isnt context0 - throw new Error "HTML template is ill-formed (at #{context}): #{template}" - output = if parts.length is 0 then '""' else parts.join ' + ' - [output, text] - -html = (template) -> - [output, remaining] = parseTemplate template - if remaining - throw new Error "Unexpected characters in template (#{remaining}): #{template}" - "(innerHTML: #{output})" - -assert = (statement, objs...) -> - return '' unless pkg.tests_enabled - "throw new Error 'Assertion failed: ' + #{json statement} unless #{statement}" - -ls = (pathname) -> fs.readdirSync pathname - -_.assign pkg, {read, readJSON, readBase64, importCSS, importHTML, html, assert, ls} - -for arg in process.argv[4..] - [key, val] = arg.match(/(.*?)=(.*)/)[1..] - pkg[key] = val - -text = read process.argv[2] -text = _.template(text)(pkg) -fs.writeFileSync process.argv[3], text