Rewrite template handler in JS, add much-needed comments and try to separate concerns.

This commit is contained in:
ccd0 2016-04-11 23:51:11 -07:00
parent 9226465b95
commit 5445327302
3 changed files with 183 additions and 94 deletions

View File

@ -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 $<

179
tools/template.js Normal file
View File

@ -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);

View File

@ -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