Skip to content

Instantly share code, notes, and snippets.

@rissem
Created July 1, 2013 19:52
Show Gist options
  • Save rissem/5903967 to your computer and use it in GitHub Desktop.
Save rissem/5903967 to your computer and use it in GitHub Desktop.
create a meteor style templates from an html file cobbled together from http://github.com/meteor/meteor
fs = require("fs");
var Handlebars = {};
/* Our format:
*
* A 'template' is an array. Each element in it is either
* - a literal string to echo
* - an escaped substition: ['{', invocation]
* - an unescaped substition: ['!', invocation]
* - a (conditional or iterated) block:
* ['#', invocation, template_a, template_b]
* (the second template is optional)
* - a partial: ['>', partial_name] (partial_name is a string)
*
* An 'invocation' is an array: one or more 'values', then an optional
* hash (of which the keys are strings, and the values are 'values'.)
*
* An 'identifier' is:
* - [depth, key, key, key..]
* Eg, '../../a.b.c' would be [2, 'a', 'b', 'c']. 'a' would be [0, 'a'].
* And 'this' or '.' would be [0].
*
* A 'value' is either an identifier, or a string, int, or bool.
*
* You should provide a block helper 'with' since we will emit calls
* to it (if the user passes the second 'context' argument to a
* partial.)
*/
Handlebars.to_json_ast = function (code) {
// We need handlebars and underscore, but this is bundle time, so
// we load them using 'require'.
// If we're in a unit test right now, we're actually in the server
// run-time environment; we have '_' but not 'require'.
// This is all very hacky.
var req = (typeof require === 'undefined' ?
Npm.require : require);
var path = req('path');
var _ = req("underscore");
var ast = req("handlebars").parse(code);
// Recreate Handlebars.Exception to properly report error messages
// and stack traces. (https://github.com/wycats/handlebars.js/issues/226)
makeHandlebarsExceptionsVisible(req);
var identifier = function (node) {
if (node.type !== "ID")
throw new Error("got ast node " + node.type + " for identifier");
// drop node.isScoped. this is true if there was a 'this' or '.'
// anywhere in the path. vanilla handlebars will turn off
// helpers lookup if isScoped is true, but this is too restrictive
// for us.
var ret = [node.depth];
// we still want to turn off helper lookup if path starts with 'this.'
// as in {{this.foo}}, which means it has to look different from {{foo}}
// in our AST. signal the presence of 'this' in our AST using an empty
// path segment.
if (/^this\./.test(node.original))
ret.push('');
return ret.concat(node.parts);
};
var value = function (node) {
// Work around handlebars.js Issue #422 - Negative integers for
// helpers get trapped as ID. handlebars doesn't support floating
// point, just integers.
if (node.type === 'ID' && /^-\d+$/.test(node.string)) {
// Reconstruct node
node.type = 'INTEGER';
node.integer = node.string;
}
var choices = {
ID: function (node) {return identifier(node);},
STRING: function (node) {return node.string;},
INTEGER: function (node) {return +node.integer;},
BOOLEAN: function (node) {return (node.bool === 'true');}
};
if (!(node.type in choices))
throw new Error("got ast node " + node.type + " for value");
return choices[node.type](node);
};
var hash = function (node) {
if (node.type !== "hash")
throw new Error("got ast node " + node.type + " for hash");
var ret = {};
_.each(node.pairs, function (p) {
ret[p[0]] = value(p[1]);
});
return ret;
};
var invocation = function (node) {
if (node.type !== "mustache")
throw new Error("got ast node " + node.type + " for invocation");
var ret = [node.id];
ret = ret.concat(node.params);
ret = _.map(ret, value);
if (node.hash)
ret.push(hash(node.hash));
return ret;
};
var template = function (nodes) {
var ret = [];
if (!nodes)
return [];
var choices = {
mustache: function (node) {
ret.push([node.escaped ? '{' : '!', invocation(node)]);
},
partial: function (node) {
var id = identifier(node.id);
if (id.length !== 2 || id[0] !== 0)
// XXX actually should just get the literal string the
// entered, and avoid identifier parsing
throw new Error("Template names shouldn't contain '.' or '/'");
var x = ['>', id[1]];
if (node.context)
x = ['#', [[0, 'with'], identifier(node.context)], [x]];
ret.push(x);
},
block: function (node) {
var x = ['#', invocation(node.mustache),
template(node.program.statements)];
if (node.program.inverse)
x.push(template(node.program.inverse.statements));
ret.push(x);
},
inverse: function (node) {
ret.push(['#', invocation(node.mustache),
node.program.inverse &&
template(node.program.inverse.statements) || [],
template(node.program.statements)]);
},
content: function (node) {ret.push(node.string);},
comment: function (node) {}
};
_.each(nodes, function (node) {
if (!(node.type in choices))
throw new Error("got ast node " + node.type + " in template");
choices[node.type](node);
});
return ret;
};
if (ast.type !== "program")
throw new Error("got ast node " + node.type + " at toplevel");
return template(ast.statements);
};
var makeHandlebarsExceptionsVisible = function (req) {
req("handlebars").Exception = function(message) {
this.message = message;
// In Node, if we don't do this we don't see the message displayed
// nor the right stack trace.
Error.captureStackTrace(this, arguments.callee);
};
req("handlebars").Exception.prototype = new Error();
req("handlebars").Exception.prototype.name = 'Handlebars.Exception';
};
var html_scanner = {
// Scan a template file for <head>, <body>, and <template>
// tags and extract their contents.
//
// This is a primitive, regex-based scanner. It scans
// top-level tags, which are allowed to have attributes,
// and ignores top-level HTML comments.
scan: function (contents, source_name) {
var rest = contents;
var index = 0;
var advance = function(amount) {
rest = rest.substring(amount);
index += amount;
};
var parseError = function(msg) {
var lineNumber = contents.substring(0, index).split('\n').length;
var line = contents.split('\n')[lineNumber - 1];
var info = "line "+lineNumber+", file "+source_name + "\n" + line;
return new Error((msg || "Parse error")+" - "+info);
};
var results = html_scanner._initResults();
var rOpenTag = /^((<(template|head|body)\b)|(<!--)|(<!DOCTYPE|{{!)|$)/i;
while (rest) {
// skip whitespace first (for better line numbers)
advance(rest.match(/^\s*/)[0].length);
var match = rOpenTag.exec(rest);
if (! match)
throw parseError(); // unknown text encountered
var matchToken = match[1];
var matchTokenTagName = match[3];
var matchTokenComment = match[4];
var matchTokenUnsupported = match[5];
advance(match.index + match[0].length);
if (! matchToken)
break; // matched $ (end of file)
if (matchTokenComment === '<!--') {
// top-level HTML comment
var commentEnd = /--\s*>/.exec(rest);
if (! commentEnd)
throw parseError("unclosed HTML comment");
advance(commentEnd.index + commentEnd[0].length);
continue;
}
if (matchTokenUnsupported) {
switch (matchTokenUnsupported.toLowerCase()) {
case '<!doctype':
throw parseError(
"Can't set DOCTYPE here. (Meteor sets <!DOCTYPE html> for you)");
case '{{!':
throw new parseError(
"Can't use '{{! }}' outside a template. Use '<!-- -->'.");
}
throw new parseError();
}
// otherwise, a <tag>
var tagName = matchTokenTagName.toLowerCase();
var tagAttribs = {}; // bare name -> value dict
var rTagPart = /^\s*((([a-zA-Z0-9:_-]+)\s*=\s*(["'])(.*?)\4)|(>))/;
var attr;
// read attributes
while ((attr = rTagPart.exec(rest))) {
var attrToken = attr[1];
var attrKey = attr[3];
var attrValue = attr[5];
advance(attr.index + attr[0].length);
if (attrToken === '>')
break;
// XXX we don't HTML unescape the attribute value
// (e.g. to allow "abcd&quot;efg") or protect against
// collisions with methods of tagAttribs (e.g. for
// a property named toString)
attrValue = attrValue.match(/^\s*([\s\S]*?)\s*$/)[1]; // trim
tagAttribs[attrKey] = attrValue;
}
if (! attr) // didn't end on '>'
throw new parseError("Parse error in tag");
// find </tag>
var end = (new RegExp('</'+tagName+'\\s*>', 'i')).exec(rest);
if (! end)
throw new parseError("unclosed <"+tagName+">");
var tagContents = rest.slice(0, end.index);
advance(end.index + end[0].length);
// act on the tag
html_scanner._handleTag(results, tagName, tagAttribs, tagContents,
parseError);
}
return results;
},
_initResults: function() {
var results = {};
results.head = '';
results.body = '';
results.js = '';
return results;
},
_handleTag: function (results, tag, attribs, contents, parseError) {
// trim the tag contents.
// this is a courtesy and is also relied on by some unit tests.
contents = contents.match(/^[ \t\r\n]*([\s\S]*?)[ \t\r\n]*$/)[1];
// do we have 1 or more attribs?
var hasAttribs = false;
for(var k in attribs) {
if (attribs.hasOwnProperty(k)) {
hasAttribs = true;
break;
}
}
if (tag === "head") {
if (hasAttribs)
throw parseError("Attributes on <head> not supported");
results.head += contents;
return;
}
// <body> or <template>
var code = 'Handlebars.json_ast_to_func(' +
JSON.stringify(Handlebars.to_json_ast(contents)) + ')';
if (tag === "template") {
var name = attribs.name;
if (! name)
throw parseError("Template has no 'name' attribute");
results.js += "Meteor._def_template(" + JSON.stringify(name) + ","
+ code + ");\n";
} else {
// <body>
if (hasAttribs)
throw parseError("Attributes on <body> not supported");
results.js += "Meteor.startup(function(){" +
"document.body.appendChild(Spark.render(" +
"Meteor._def_template(null," + code + ")));});";
}
}
};
var contents = fs.readFileSync("/tmp/test.html", "utf-8");
var results = html_scanner.scan(contents, "test.html");
console.log("RESULTS", results);
// If we are running at bundle time, set module.exports.
// For unit testing in server environment, don't.
if (typeof module !== 'undefined')
module.exports = html_scanner;
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment