Rewrite template handler in JS, add much-needed comments and try to separate concerns.
This commit is contained in:
parent
9226465b95
commit
5445327302
11
Makefile
11
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 $<
|
||||
|
||||
179
tools/template.js
Normal file
179
tools/template.js
Normal 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);
|
||||
@ -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
|
||||
Loading…
x
Reference in New Issue
Block a user