// Copyright (C) 2009 Andy Chu // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // $Id$ // // JavaScript implementation of json-template. // // This is predefined in tests, shouldn't be defined anywhere else. TODO: Do // something nicer. var log = log || function() {}; var repr = repr || function() {}; // The "module" exported by this script is called "jsontemplate": var jsontemplate = function() { // Regex escaping for metacharacters function EscapeMeta(meta) { return meta.replace(/([\{\}\(\)\[\]\|\^\$\-\+\?])/g, '\\$1'); } var token_re_cache = {}; function _MakeTokenRegex(meta_left, meta_right) { var key = meta_left + meta_right; var regex = token_re_cache[key]; if (regex === undefined) { var str = '(' + EscapeMeta(meta_left) + '.*?' + EscapeMeta(meta_right) + '\n?)'; regex = new RegExp(str, 'g'); } return regex; } // // Formatters // function HtmlEscape(s) { return s.replace(/&/g,'&'). replace(/>/g,'>'). replace(/</g,'<'); } function HtmlTagEscape(s) { return s.replace(/&/g,'&'). replace(/>/g,'>'). replace(/</g,'<'). replace(/"/g,'"'); } // Default ToString can be changed function ToString(s) { if (s === null) { return 'null'; } return s.toString(); } // Formatter to pluralize words function _Pluralize(value, unused_context, args) { var s, p; switch (args.length) { case 0: s = ''; p = 's'; break; case 1: s = ''; p = args[0]; break; case 2: s = args[0]; p = args[1]; break; default: // Should have been checked at compile time throw { name: 'EvaluationError', message: 'pluralize got too many args' }; } return (value > 1) ? p : s; } function _Cycle(value, unused_context, args) { // Cycle between various values on consecutive integers. // @index starts from 1, so use 1-based indexing. return args[(value - 1) % args.length]; } var DEFAULT_FORMATTERS = { 'html': HtmlEscape, 'htmltag': HtmlTagEscape, 'html-attr-value': HtmlTagEscape, 'str': ToString, 'raw': function(x) { return x; }, 'AbsUrl': function(value, context) { // TODO: Normalize leading/trailing slashes return context.get('base-url') + '/' + value; } }; var DEFAULT_PREDICATES = { 'singular?': function(x) { return x == 1; }, 'plural?': function(x) { return x > 1; }, 'Debug?': function(unused, context) { try { return context.get('debug'); } catch(err) { if (err.name == 'UndefinedVariable') { return false; } else { throw err; } } } }; var FunctionRegistry = function() { return { lookup: function(user_str) { return [null, null]; } }; }; var SimpleRegistry = function(obj) { return { lookup: function(user_str) { var func = obj[user_str] || null; return [func, null]; } }; }; var CallableRegistry = function(callable) { return { lookup: function(user_str) { var func = callable(user_str); return [func, null]; } }; }; // Default formatters which can't be expressed in DEFAULT_FORMATTERS var PrefixRegistry = function(functions) { return { lookup: function(user_str) { for (var i = 0; i < functions.length; i++) { var name = functions[i].name, func = functions[i].func; if (user_str.slice(0, name.length) == name) { // Delimiter is usually a space, but could be something else var args; var splitchar = user_str.charAt(name.length); if (splitchar === '') { args = []; // No arguments } else { args = user_str.split(splitchar).slice(1); } return [func, args]; } } return [null, null]; // No formatter } }; }; var ChainedRegistry = function(registries) { return { lookup: function(user_str) { for (var i=0; i<registries.length; i++) { var result = registries[i].lookup(user_str); if (result[0]) { return result; } } return [null, null]; // Nothing found } }; }; // // Template implementation // function _ScopedContext(context, undefined_str) { // The stack contains: // The current context (an object). // An iteration index. -1 means we're NOT iterating. var stack = [{context: context, index: -1}]; return { PushSection: function(name) { if (name === undefined || name === null) { return null; } var new_context; if (name == '@') { new_context = stack[stack.length-1].context; } else { new_context = stack[stack.length-1].context[name] || null; } stack.push({context: new_context, index: -1}); return new_context; }, Pop: function() { stack.pop(); }, next: function() { var stacktop = stack[stack.length-1]; // Now we're iterating -- push a new mutable object onto the stack if (stacktop.index == -1) { stacktop = {context: null, index: 0}; stack.push(stacktop); } // The thing we're iterating over var context_array = stack[stack.length-2].context; // We're already done if (stacktop.index == context_array.length) { stack.pop(); return undefined; // sentinel to say that we're done } stacktop.context = context_array[stacktop.index++]; return true; // OK, we mutated the stack }, _Undefined: function(name) { if (undefined_str === undefined) { throw { name: 'UndefinedVariable', message: name + ' is not defined' }; } else { return undefined_str; } }, _LookUpStack: function(name) { var i = stack.length - 1; while (true) { var frame = stack[i]; if (name == '@index') { if (frame.index != -1) { // -1 is undefined return frame.index; } } else { var context = frame.context; if (typeof context === 'object') { var value = context[name]; if (value !== undefined) { return value; } } } i--; if (i <= -1) { return this._Undefined(name); } } }, get: function(name) { if (name == '@') { return stack[stack.length-1].context; } var parts = name.split('.'); var value = this._LookUpStack(parts[0]); if (parts.length > 1) { for (var i=1; i<parts.length; i++) { value = value[parts[i]]; if (value === undefined) { return this._Undefined(parts[i]); } } } return value; } }; } // Crockford's "functional inheritance" pattern var _AbstractSection = function(spec) { var that = {}; that.current_clause = []; that.Append = function(statement) { that.current_clause.push(statement); }; that.AlternatesWith = function() { throw { name: 'TemplateSyntaxError', message: '{.alternates with} can only appear with in {.repeated section ...}' }; }; that.NewOrClause = function(pred) { throw { name: 'NotImplemented' }; // "Abstract" }; return that; }; var _Section = function(spec) { var that = _AbstractSection(spec); that.statements = {'default': that.current_clause}; that.section_name = spec.section_name; that.Statements = function(clause) { clause = clause || 'default'; return that.statements[clause] || []; }; that.NewOrClause = function(pred) { if (pred) { throw { name: 'TemplateSyntaxError', message: '{.or} clause only takes a predicate inside predicate blocks' }; } that.current_clause = []; that.statements['or'] = that.current_clause; }; return that; }; // Repeated section is like section, but it supports {.alternates with} var _RepeatedSection = function(spec) { var that = _Section(spec); that.AlternatesWith = function() { that.current_clause = []; that.statements['alternate'] = that.current_clause; }; return that; }; // Represents a sequence of predicate clauses. var _PredicateSection = function(spec) { var that = _AbstractSection(spec); // Array of func, statements that.clauses = []; that.NewOrClause = function(pred) { // {.or} always executes if reached, so use identity func with no args pred = pred || [function(x) { return true; }, null]; that.current_clause = []; that.clauses.push([pred, that.current_clause]); }; return that; }; function _Execute(statements, context, callback) { for (var i=0; i<statements.length; i++) { var statement = statements[i]; if (typeof(statement) == 'string') { callback(statement); } else { var func = statement[0]; var args = statement[1]; func(args, context, callback); } } } function _DoSubstitute(statement, context, callback) { var value; value = context.get(statement.name); // Format values for (var i=0; i<statement.formatters.length; i++) { var pair = statement.formatters[i]; var formatter = pair[0]; var args = pair[1]; value = formatter(value, context, args); } callback(value); } // for [section foo] function _DoSection(args, context, callback) { var block = args; var value = context.PushSection(block.section_name); var do_section = false; // "truthy" values should have their sections executed. if (value) { do_section = true; } // Except: if the value is a zero-length array (which is "truthy") if (value && value.length === 0) { do_section = false; } if (do_section) { _Execute(block.Statements(), context, callback); context.Pop(); } else { // Empty list, None, False, etc. context.Pop(); _Execute(block.Statements('or'), context, callback); } } // {.pred1?} A {.or pred2?} B ... {.or} Z {.end} function _DoPredicates(args, context, callback) { // Here we execute the first clause that evaluates to true, and then stop. var block = args; var value = context.get('@'); for (var i=0; i<block.clauses.length; i++) { var clause = block.clauses[i]; var predicate = clause[0][0]; var pred_args = clause[0][1]; var statements = clause[1]; var do_clause = predicate(value, context, pred_args); if (do_clause) { _Execute(statements, context, callback); break; } } } function _DoRepeatedSection(args, context, callback) { var block = args; items = context.PushSection(block.section_name); pushed = true; if (items && items.length > 0) { // TODO: check that items is an array; apparently this is hard in JavaScript //if type(items) is not list: // raise EvaluationError('Expected a list; got %s' % type(items)) // Execute the statements in the block for every item in the list. // Execute the alternate block on every iteration except the last. Each // item could be an atom (string, integer, etc.) or a dictionary. var last_index = items.length - 1; var statements = block.Statements(); var alt_statements = block.Statements('alternate'); for (var i=0; context.next() !== undefined; i++) { _Execute(statements, context, callback); if (i != last_index) { _Execute(alt_statements, context, callback); } } } else { _Execute(block.Statements('or'), context, callback); } context.Pop(); } var _SECTION_RE = /(repeated)?\s*(section)\s+(\S+)?/; var _OR_RE = /or(?:\s+(.+))?/; var _IF_RE = /if(?:\s+(.+))?/; // Turn a object literal, function, or Registry into a Registry function MakeRegistry(obj) { if (!obj) { // if null/undefined, use a totally empty FunctionRegistry return new FunctionRegistry(); } else if (typeof obj === 'function') { return new CallableRegistry(obj); } else if (obj.lookup !== undefined) { // TODO: Is this a good pattern? There is a namespace conflict where get // could be either a formatter or a method on a FunctionRegistry. // instanceof might be more robust. return obj; } else if (typeof obj === 'object') { return new SimpleRegistry(obj); } } // TODO: The compile function could be in a different module, in case we want to // compile on the server side. function _Compile(template_str, options) { var more_formatters = MakeRegistry(options.more_formatters); // default formatters with arguments var default_formatters = PrefixRegistry([ {name: 'pluralize', func: _Pluralize}, {name: 'cycle', func: _Cycle} ]); var all_formatters = new ChainedRegistry([ more_formatters, SimpleRegistry(DEFAULT_FORMATTERS), default_formatters ]); var more_predicates = MakeRegistry(options.more_predicates); // TODO: Add defaults var all_predicates = new ChainedRegistry([ more_predicates, SimpleRegistry(DEFAULT_PREDICATES) ]); // We want to allow an explicit null value for default_formatter, which means // that an error is raised if no formatter is specified. var default_formatter; if (options.default_formatter === undefined) { default_formatter = 'str'; } else { default_formatter = options.default_formatter; } function GetFormatter(format_str) { var pair = all_formatters.lookup(format_str); if (!pair[0]) { throw { name: 'BadFormatter', message: format_str + ' is not a valid formatter' }; } return pair; } function GetPredicate(pred_str) { var pair = all_predicates.lookup(pred_str); if (!pair[0]) { throw { name: 'BadPredicate', message: pred_str + ' is not a valid predicate' }; } return pair; } var format_char = options.format_char || '|'; if (format_char != ':' && format_char != '|') { throw { name: 'ConfigurationError', message: 'Only format characters : and | are accepted' }; } var meta = options.meta || '{}'; var n = meta.length; if (n % 2 == 1) { throw { name: 'ConfigurationError', message: meta + ' has an odd number of metacharacters' }; } var meta_left = meta.substring(0, n/2); var meta_right = meta.substring(n/2, n); var token_re = _MakeTokenRegex(meta_left, meta_right); var current_block = _Section({}); var stack = [current_block]; var strip_num = meta_left.length; // assume they're the same length var token_match; var last_index = 0; while (true) { token_match = token_re.exec(template_str); if (token_match === null) { break; } else { var token = token_match[0]; } // Add the previous literal to the program if (token_match.index > last_index) { var tok = template_str.slice(last_index, token_match.index); current_block.Append(tok); } last_index = token_re.lastIndex; var had_newline = false; if (token.slice(-1) == '\n') { token = token.slice(null, -1); had_newline = true; } token = token.slice(strip_num, -strip_num); if (token.charAt(0) == '#') { continue; // comment } if (token.charAt(0) == '.') { // Keyword token = token.substring(1, token.length); var literal = { 'meta-left': meta_left, 'meta-right': meta_right, 'space': ' ', 'tab': '\t', 'newline': '\n' }[token]; if (literal !== undefined) { current_block.Append(literal); continue; } var new_block, func; var section_match = token.match(_SECTION_RE); if (section_match) { var repeated = section_match[1]; var section_name = section_match[3]; if (repeated) { func = _DoRepeatedSection; new_block = _RepeatedSection({section_name: section_name}); } else { func = _DoSection; new_block = _Section({section_name: section_name}); } current_block.Append([func, new_block]); stack.push(new_block); current_block = new_block; continue; } var pred_str, pred; // Check {.or pred?} before {.pred?} var or_match = token.match(_OR_RE); if (or_match) { pred_str = or_match[1]; pred = pred_str ? GetPredicate(pred_str) : null; current_block.NewOrClause(pred); continue; } // Match either {.pred?} or {.if pred?} var matched = false; var if_match = token.match(_IF_RE); if (if_match) { pred_str = if_match[1]; matched = true; } else if (token.charAt(token.length-1) == '?') { pred_str = token; matched = true; } if (matched) { pred = pred_str ? GetPredicate(pred_str) : null; new_block = _PredicateSection(); new_block.NewOrClause(pred); current_block.Append([_DoPredicates, new_block]); stack.push(new_block); current_block = new_block; continue; } if (token == 'alternates with') { current_block.AlternatesWith(); continue; } if (token == 'end') { // End the block stack.pop(); if (stack.length > 0) { current_block = stack[stack.length-1]; } else { throw { name: 'TemplateSyntaxError', message: 'Got too many {end} statements' }; } continue; } } // A variable substitution var parts = token.split(format_char); var formatters; var name; if (parts.length == 1) { if (default_formatter === null) { throw { name: 'MissingFormatter', message: 'This template requires explicit formatters.' }; } // If no formatter is specified, use the default. formatters = [GetFormatter(default_formatter)]; name = token; } else { formatters = []; for (var j=1; j<parts.length; j++) { formatters.push(GetFormatter(parts[j])); } name = parts[0]; } current_block.Append([_DoSubstitute, {name: name, formatters: formatters}]); if (had_newline) { current_block.Append('\n'); } } // Add the trailing literal current_block.Append(template_str.slice(last_index)); if (stack.length !== 1) { throw { name: 'TemplateSyntaxError', message: 'Got too few {end} statements' }; } return current_block; } // The Template class is defined in the traditional style so that users can add // methods by mutating the prototype attribute. TODO: Need a good idiom for // inheritance without mutating globals. function Template(template_str, options) { // Add 'new' if we were not called with 'new', so prototyping works. if(!(this instanceof Template)) { return new Template(template_str, options); } this._options = options || {}; this._program = _Compile(template_str, this._options); } Template.prototype.render = function(data_dict, callback) { // options.undefined_str can either be a string or undefined var context = _ScopedContext(data_dict, this._options.undefined_str); _Execute(this._program.Statements(), context, callback); }; Template.prototype.expand = function(data_dict) { var tokens = []; this.render(data_dict, function(x) { tokens.push(x); }); return tokens.join(''); }; // fromString is a construction method that allows metadata to be written at the // beginning of the template string. See Python's FromFile for a detailed // description of the format. // // The argument 'options' takes precedence over the options in the template, and // can be used for non-serializable options like template formatters. var OPTION_RE = /^([a-zA-Z\-]+):\s*(.*)/; var OPTION_NAMES = [ 'meta', 'format-char', 'default-formatter', 'undefined-str']; // Use this "linear search" instead of Array.indexOf, which is nonstandard var OPTION_NAMES_RE = new RegExp(OPTION_NAMES.join('|')); function fromString(s, options) { var parsed = {}; var begin = 0, end = 0; while (true) { var parsedOption = false; end = s.indexOf('\n', begin); if (end == -1) { break; } var line = s.slice(begin, end); begin = end+1; var match = line.match(OPTION_RE); if (match !== null) { var name = match[1].toLowerCase(), value = match[2]; if (name.match(OPTION_NAMES_RE)) { name = name.replace('-', '_'); value = value.replace(/^\s+/, '').replace(/\s+$/, ''); if (name == 'default_formatter' && value.toLowerCase() == 'none') { value = null; } parsed[name] = value; parsedOption = true; } } if (!parsedOption) { break; } } // TODO: This doesn't enforce the blank line between options and template, but // that might be more trouble than it's worth if (parsed !== {}) { body = s.slice(begin); } else { body = s; } for (var o in options) { parsed[o] = options[o]; } return Template(body, parsed); } // We just export one name for now, the Template "class". // We need HtmlEscape in the browser tests, so might as well export it. return { Template: Template, HtmlEscape: HtmlEscape, FunctionRegistry: FunctionRegistry, SimpleRegistry: SimpleRegistry, CallableRegistry: CallableRegistry, ChainedRegistry: ChainedRegistry, fromString: fromString, // Private but exposed for testing _Section: _Section }; }();