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 := $(BIN)coffee -c --no-header
|
||||||
coffee_deps := node_modules/coffee-script/package.json
|
coffee_deps := node_modules/coffee-script/package.json
|
||||||
template := $(BIN)coffee tools/templates.coffee
|
template := node tools/template.js
|
||||||
template_deps := \
|
template_deps := package.json version.json tools/template.js node_modules/lodash/package.json
|
||||||
package.json version.json \
|
|
||||||
tools/templates.coffee \
|
|
||||||
node_modules/coffee-script/package.json node_modules/lodash/package.json
|
|
||||||
cat := node tools/cat.js
|
cat := node tools/cat.js
|
||||||
cat_deps := tools/cat.js
|
cat_deps := tools/cat.js
|
||||||
jshint_deps := .jshintrc node_modules/jshint/package.json
|
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
|
test.html : README.md template.jst tools/markdown.js node_modules/marked/package.json node_modules/lodash/package.json
|
||||||
node tools/markdown.js
|
node tools/markdown.js
|
||||||
|
|
||||||
.jshintrc : tools/templates.coffee src/meta/jshint.json $(template_deps)
|
.jshintrc : src/meta/jshint.json $(template_deps)
|
||||||
$(template) src/meta/jshint.json .jshintrc
|
$(template) $< .jshintrc
|
||||||
|
|
||||||
.events/jshint.% : tmp/%.js $(jshint_deps) | .events
|
.events/jshint.% : tmp/%.js $(jshint_deps) | .events
|
||||||
$(BIN)jshint $<
|
$(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