Implement new # and > calling convention

Both {{#foo}} and {{> foo}} are handled as follows:

- foo may be a template or a function returning a template
- if there are only keyword arguments, they are used to create a data context object (e.g. `x=1 y=2` becomes `{x:1, y:2}`)
- if there are any positional arguments, the arguments are treated as a nested helper call, meaning that the first one will be called on the rest (including keyword arguments) if it is a function

Rendering-wise, the template should only be re-rendered if foo is a function and it is invalidated to return a different value (though the "isolate" that would compare the new value to the old is not on this branch at the moment).

This change also makes the built-in block helpers (#if, #each, etc.) be functions (render macros) instead of components (though #each is still implemented with a component).  This avoids data context issues with #if and #unless (the new calling convention isn't really designed to support making something like #if).  It also should make generated code cleaner to call `UI.If` instead of including it as a component.
This commit is contained in:
David Greenspan
2014-01-24 13:22:02 -08:00
parent fc955af5c0
commit a9c4dfaecd
7 changed files with 294 additions and 283 deletions

View File

@@ -143,36 +143,64 @@ var optimize = function (tree) {
// ============================================================
// Code-generation of template tags
var builtInComponents = {
'content': '__content',
'elseContent': '__elseContent',
var builtInBlockHelpers = {
'if': 'UI.If',
'unless': 'UI.Unless',
'with': 'UI.With',
'with': 'Spacebars.With',
'each': 'UI.Each'
};
var builtInLexicals = {
'content': 'template.__content',
'elseContent': 'template.__elseContent'
};
var codeGenTemplateTag = function (tag) {
if (tag.position === HTML.TEMPLATE_TAG_POSITION.IN_START_TAG) {
// only `tag.type === 'DOUBLE'` allowed (by earlier validation)
return HTML.EmitCode('function () { return ' +
codeGenMustache(tag, 'attrMustache') + '; }');
codeGenMustache(tag.path, tag.args, 'attrMustache')
+ '; }');
} else {
if (tag.type === 'DOUBLE') {
return HTML.EmitCode('function () { return ' +
codeGenMustache(tag) + '; }');
codeGenMustache(tag.path, tag.args) + '; }');
} else if (tag.type === 'TRIPLE') {
return HTML.EmitCode('function () { return Spacebars.makeRaw(' +
codeGenMustache(tag) + '); }');
codeGenMustache(tag.path, tag.args) + '); }');
} else if (tag.type === 'INCLUSION' || tag.type === 'BLOCKOPEN') {
var path = tag.path;
var compCode = codeGenPath(path);
if (path.length === 1) {
var compName = path[0];
if (builtInComponents.hasOwnProperty(compName)) {
compCode = builtInComponents[compName];
} else {
if (tag.type === 'BLOCKOPEN' &&
builtInBlockHelpers.hasOwnProperty(path[0])) {
// if, unless, with, each.
//
// If someone tries to do `{{> if}}`, we don't
// get here, but an error is thrown when we try to codegen the path.
// Note: If we caught these errors earlier, while scanning, we'd be able to
// provide nice line numbers.
if (path.length > 1)
throw new Error("Unexpected dotted path beginning with " + path[0]);
if (! tag.args.length)
throw new Error("#" + path[0] + " requires an argument");
var info = codeGenInclusionArgs(tag);
var dataFunc = info.dataFuncCode; // must exist (tag.args.length > 0)
var contentBlock = info.extraArgs.content; // must exist
var elseContentBlock = info.extraArgs.elseContent; // may not exist
var callArgs = [dataFunc, contentBlock];
if (elseContentBlock)
callArgs.push(elseContentBlock);
return HTML.EmitCode(
builtInBlockHelpers[path[0]] + '(' + callArgs.join(', ') + ')');
} else {
var compCode = codeGenPath(path);
if (path.length === 1) {
// toObjectLiteralKey returns `"foo"` or `foo` depending on
// whether `foo` is a safe JavaScript identifier.
var member = toObjectLiteralKey(path[0]);
@@ -180,15 +208,24 @@ var codeGenTemplateTag = function (tag) {
'Template[' + member + ']' :
'Template.' + member);
compCode = ('(' + templateDotFoo + ' || ' + compCode + ')');
} else {
// path code may be reactive; wrap it
compCode = 'function () { return ' + compCode + '; }';
}
var argGen = codeGenInclusionArgs(tag);
var dataFuncCode = argGen.dataFuncCode;
var extraArgs = argGen.extraArgs;
var includeArgs = [compCode];
if (dataFuncCode || extraArgs)
includeArgs.push(dataFuncCode || 'null');
if (extraArgs)
includeArgs.push(makeObjectLiteral(extraArgs));
return HTML.EmitCode(
'Spacebars.include(' + includeArgs.join(', ') + ')');
}
var includeArgs = codeGenInclusionArgs(tag);
return HTML.EmitCode(
'function () { return Spacebars.include(' + compCode +
(includeArgs.length ? ', ' + includeArgs.join(', ') : '') +
'); }');
} else {
// Can't get here; TemplateTag validation should catch any
// inappropriate tag types that might come out of the parser.
@@ -204,97 +241,6 @@ var makeObjectLiteral = function (obj) {
return '{' + parts.join(', ') + '}';
};
var codeGenInclusionArgs = function (tag) {
var args = null;
var posArgs = [];
if ('content' in tag) {
args = (args || {});
args.__content = (
'UI.block(' + Spacebars.codeGen(tag.content) + ')');
}
if ('elseContent' in tag) {
args = (args || {});
args.__elseContent = (
'UI.block(' + Spacebars.codeGen(tag.elseContent) + ')');
}
// precalculate the number of positional args
var numPosArgs = 0;
_.each(tag.args, function (arg) {
if (arg.length === 2)
numPosArgs++;
});
_.each(tag.args, function (arg) {
var argType = arg[0];
var argValue = arg[1];
var isKeyword = (arg.length > 2);
var argCode;
switch (argType) {
case 'STRING':
case 'NUMBER':
case 'BOOLEAN':
case 'NULL':
argCode = toJSLiteral(argValue);
break;
case 'PATH':
var path = argValue;
argCode = codeGenPath(path);
// a single-segment path will compile to something like
// `self.lookup("foo")` which never establishes any dependencies,
// while `Spacebars.dot(self.lookup("foo"), "bar")` may establish
// dependencies.
//
// In the multi-positional-arg construct, don't wrap pos args here.
if (! ((path.length === 1) || (numPosArgs > 1)))
argCode = 'function () { return Spacebars.call(' + argCode + '); }';
break;
default:
// can't get here
throw new Error("Unexpected arg type: " + argType);
}
if (isKeyword) {
// keyword argument (represented as [type, value, name])
var name = arg[2];
args = (args || {});
args[name] = argCode;
} else {
// positional argument
posArgs.push(argCode);
}
});
if (posArgs.length === 1) {
args = (args || {});
args.data = posArgs[0];
} else if (posArgs.length > 1) {
// only allowed for block helper (which has already been
// checked at parse time); call first
// argument as a function on the others
args = (args || {});
args.data = 'function () { return Spacebars.call(' + posArgs.join(', ') + '); }';
}
if (args)
return [makeObjectLiteral(args)];
return [];
};
var codeGenMustache = function (tag, mustacheType) {
var nameCode = codeGenPath(tag.path);
var argCode = codeGenArgs(tag.args);
var mustache = (mustacheType || 'mustache');
return 'Spacebars.' + mustache + '(' + nameCode +
(argCode ? ', ' + argCode.join(', ') : '') + ')';
};
// `path` is an array of at least one string.
//
// If `path.length > 1`, the generated code may be reactive
@@ -302,12 +248,15 @@ var codeGenMustache = function (tag, mustacheType) {
//
// No code is generated to call the result if it's a function.
var codeGenPath = function (path) {
// Let {{#if content}} check whether this template was invoked via
// inclusion or as a block helper.
if (builtInComponents.hasOwnProperty(path[0])) {
if (builtInBlockHelpers.hasOwnProperty(path[0]))
throw new Error("Can't use the built-in '" + path[0] + "' here");
// Let `{{#if content}}` check whether this template was invoked via
// inclusion or as a block helper, in addition to supporting
// `{{> content}}`.
if (builtInLexicals.hasOwnProperty(path[0])) {
if (path.length > 1)
throw new Error("Unexpected dotted path beginning with " + path[0]);
return builtInComponents[path[0]];
return builtInLexicals[path[0]];
}
var code = 'self.lookup(' + toJSLiteral(path[0]) + ')';
@@ -320,31 +269,55 @@ var codeGenPath = function (path) {
return code;
};
// Generates code for an `[argType, argValue]` argument spec,
// ignoring the third element (keyword argument name) if present.
//
// The resulting code may be reactive (in the case of a PATH of
// more than one element) and is not wrapped in a closure.
var codeGenArgValue = function (arg) {
var argType = arg[0];
var argValue = arg[1];
var argCode;
switch (argType) {
case 'STRING':
case 'NUMBER':
case 'BOOLEAN':
case 'NULL':
argCode = toJSLiteral(argValue);
break;
case 'PATH':
argCode = codeGenPath(argValue);
break;
default:
// can't get here
throw new Error("Unexpected arg type: " + argType);
}
return argCode;
};
// Generates a call to `Spacebars.fooMustache` on evaluated arguments.
// The resulting code has no function literals and must be wrapped in
// one for fine-grained reactivity.
var codeGenMustache = function (path, args, mustacheType) {
var nameCode = codeGenPath(path);
var argCode = codeGenMustacheArgs(args);
var mustache = (mustacheType || 'mustache');
return 'Spacebars.' + mustache + '(' + nameCode +
(argCode ? ', ' + argCode.join(', ') : '') + ')';
};
// returns: array of source strings, or null if no
// args at all.
var codeGenArgs = function (tagArgs) {
var codeGenMustacheArgs = function (tagArgs) {
var kwArgs = null; // source -> source
var args = null; // [source]
// tagArgs may be null
_.each(tagArgs, function (arg) {
var argType = arg[0];
var argValue = arg[1];
var argCode;
switch (argType) {
case 'STRING':
case 'NUMBER':
case 'BOOLEAN':
case 'NULL':
argCode = toJSLiteral(argValue);
break;
case 'PATH':
argCode = codeGenPath(argValue);
break;
default:
// can't get here
throw new Error("Unexpected arg type: " + argType);
}
var argCode = codeGenArgValue(arg);
if (arg.length > 2) {
// keyword argument (represented as [type, value, name])
@@ -366,6 +339,59 @@ var codeGenArgs = function (tagArgs) {
return args;
};
// Returns an object containing two properties, both optional
// (in which case they may be absent or `null`).
// These properties are for code generation of the second and
// third arguments to `Spacebars.include`.
//
// - `dataFuncCode` - source code of a data function literal
// - `extraArgs` - map of key (e.g. "content") to source code
var codeGenInclusionArgs = function (tag) {
var extraArgs = null; // [source]
if ('content' in tag) {
extraArgs = (extraArgs || {});
extraArgs.content = (
'UI.block(' + Spacebars.codeGen(tag.content) + ')');
}
if ('elseContent' in tag) {
extraArgs = (extraArgs || {});
extraArgs.elseContent = (
'UI.block(' + Spacebars.codeGen(tag.elseContent) + ')');
}
var dataFuncCode = null; // source (exclusive of `function () { ...`)
var args = tag.args;
if (! args.length) {
// e.g. `{{#foo}}`
return { extraArgs: extraArgs };
} else if (args[0].length === 3) {
// keyword arguments only, e.g. `{{> point x=1 y=2}}`
var args = {};
_.each(args, function (arg) {
var argKey = arg[2];
args[argKey] = 'Spacebars.call(' + codeGenArgValue(arg) + ')';
});
dataFuncCode = makeObjectLiteral(args);
} else if (args[0][0] !== 'PATH') {
// literal first argument, e.g. `{{> foo "blah"}}`
//
// tag validation has confirmed, in this case, that there is only
// one argument (`args.length === 1`)
dataFuncCode = codeGenArgValue(args[0]);
} else {
dataFuncCode = codeGenMustache(args[0][1], args.slice(1),
'dataMustache');
}
dataFuncCode = 'function () { return ' + dataFuncCode + '; }';
return { dataFuncCode: dataFuncCode,
extraArgs: extraArgs };
};
// ============================================================
// Main compiler
@@ -430,8 +456,7 @@ Spacebars.codeGen = function (parseTree, options) {
// support `{{> content}}` and `{{> elseContent}}` with
// lexical scope by creating a local variable in the
// template's render function.
code += 'var __content = self.__content, ' +
'__elseContent = self.__elseContent; ';
code += 'var template = this; ';
}
code += 'return ';
code += HTML.toJS(tree);

View File

@@ -399,15 +399,14 @@ var isAtBlockCloseOrElse = function (scanner) {
// nothing.
var validateTag = function (ttag, scanner) {
if (ttag.type === 'INCLUSION') {
// throw error on >1 positional arguments
var numPosArgs = 0;
if (ttag.type === 'INCLUSION' || ttag.type === 'BLOCKOPEN') {
var args = ttag.args;
for (var i = 0; i < args.length; i++)
if (args[i].length === 2)
numPosArgs++;
if (numPosArgs > 1)
scanner.fatal("Only one positional argument is allowed in {{> ... }}");
if (args.length > 1 && args[0].length === 2 && args[0][0] !== 'PATH') {
// we have a positional argument that is not a PATH followed by
// other arguments
scanner.fatal("Can't have a " + args[0][0] + " argument here followed " +
"by other arguments");
}
}
var position = ttag.position || HTML.TEMPLATE_TAG_POSITION.ELEMENT;

View File

@@ -12,63 +12,41 @@ var safeEquals = function (a, b) {
(typeof a === 'string'));
};
Spacebars.include = function (kindOrFunc, args) {
args = args || {};
if (typeof kindOrFunc === 'function') {
// function block helper
var func = kindOrFunc;
// * `templateOrFunction` - template (component) or function returning one
// * `dataFunc` - (optional) function returning data context
// * `extraArgs` - (optional) dictionary that may have `content`/`elseContent`
Spacebars.include = function (templateOrFunction, dataFunc, extraArgs) {
var result = templateOrFunction;
var hash = {};
// Call arguments if they are functions. This may cause
// reactive dependencies!
for (var k in args) {
if (k !== 'data') {
var v = args[k];
hash[k] = (typeof v === 'function' ? v() : v);
}
}
if (extraArgs) {
var underscoredArgs = {};
for (var k in extraArgs)
underscoredArgs['__'+k] = extraArgs[k];
var result;
if ('data' in args) {
var data = args.data;
data = (typeof data === 'function' ? data() : data);
result = func(data, { hash: hash });
// extend `result` with `underscoredArgs`, whether or not it's a function
if (typeof result === 'function') {
result = function () {
// todo: isolate the calculation of `templateOrFunction`
var result = templateOrFunction();
result = result.extend(underscoredArgs);
return result;
};
} else {
result = func({ hash: hash });
result = result.extend(underscoredArgs);
}
// In `{{#foo}}...{{/foo}}`, if `foo` is a function that
// returns a component, attach __content and __elseContent
// to it.
if (UI.isComponent(result) &&
(('__content' in args) || ('__elseContent' in args))) {
var extra = {};
if ('__content' in args)
extra.__content = args.__content;
if ('__elseContent' in args)
extra.__elseContent = args.__elseContent;
result = result.extend(extra);
}
if (dataFunc) {
if (typeof dataFunc !== 'function')
throw new Error("Second argument to Spacebars.include must be a function");
if (typeof result === 'function') {
var func = result;
result = UI.block(function () { return func; });
}
return result;
return UI.With(UI.emboxValue(dataFunc, safeEquals), result);
} else {
// Component
var kind = kindOrFunc;
if (! UI.isComponent(kind))
throw new Error("Expected template, found: " + kind);
// Note that there are no reactive dependencies established here.
if (args) {
var emboxedArgs = {};
for (var k in args) {
if (k === '__content' || k === '__elseContent')
emboxedArgs[k] = args[k];
else
emboxedArgs[k] = UI.emboxValue(args[k], safeEquals);
}
return kind.extend(emboxedArgs);
} else {
return kind;
}
return result;
}
};
@@ -133,6 +111,12 @@ Spacebars.attrMustache = function (value/*, args*/) {
}
};
Spacebars.dataMustache = function (value/*, args*/) {
var result = Spacebars.mustacheImpl.apply(null, arguments);
return result;
};
// Idempotently wrap in `HTML.Raw`.
//
// Called on the return value from `Spacebars.mustache` in case the
@@ -221,3 +205,9 @@ Spacebars.dot = function (value, id1/*, id2, ...*/) {
return result.apply(value, arguments);
};
};
// Implement Spacebars's #with, which renders its else case (or nothing)
// if the argument is falsy.
Spacebars.With = function (argFunc, contentBlock, elseContentBlock) {
return UI.If(argFunc, UI.With(argFunc, contentBlock), elseContentBlock);
};

81
packages/ui/builtins.js Normal file
View File

@@ -0,0 +1,81 @@
UI.If = function (argFunc, contentBlock, elseContentBlock) {
checkBlockHelperArguments('If', argFunc, contentBlock, elseContentBlock);
return function () {
if (getCondition(argFunc))
return contentBlock;
else
return elseContentBlock || null;
};
};
UI.Unless = function (argFunc, contentBlock, elseContentBlock) {
checkBlockHelperArguments('Unless', argFunc, contentBlock, elseContentBlock);
return function () {
if (! getCondition(argFunc))
return contentBlock;
else
return elseContentBlock || null;
};
};
// Unlike Spacebars.With, there's no else case and no conditional logic.
//
// We don't do any reactive emboxing of `argFunc` here; it should be done
// by the caller if efficiency and/or number of calls to the data source
// is important.
UI.With = function (argFunc, contentBlock) {
checkBlockHelperArguments('With', argFunc, contentBlock);
var block = UI.block(function () {
return contentBlock;
});
block.data = argFunc;
return block;
};
UI.Each = function (argFunc, contentBlock, elseContentBlock) {
checkBlockHelperArguments('Each', argFunc, contentBlock, elseContentBlock);
return UI.EachImpl.extend({
__sequence: argFunc,
__content: contentBlock,
__elseContent: elseContentBlock
});
};
var checkBlockHelperArguments = function (which, argFunc, contentBlock, elseContentBlock) {
if (typeof argFunc !== 'function')
throw new Error('First argument to ' + which + ' must be a function');
if (! UI.isComponent(contentBlock))
throw new Error('Second argument to ' + which + ' must be a template or UI.block');
if (elseContentBlock && ! UI.isComponent(elseContentBlock))
throw new Error('Third argument to ' + which + ' must be a template or UI.block if present');
};
// Acts like `!! conditionFunc()` except:
//
// - Empty array is considered falsy
// - The result is Deps.isolated (doesn't trigger invalidation
// as long as the condition stays truthy or stays falsy
var getCondition = function (conditionFunc) {
return Deps.isolateValue(function () {
// `condition` is emboxed; it is always a function,
// and it only triggers invalidation if its return
// value actually changes. We still need to isolate
// the calculation of whether it is truthy or falsy
// in order to not re-render if it changes from one
// truthy or falsy value to another.
var cond = conditionFunc();
// empty arrays are treated as falsey values
if (cond instanceof Array && cond.length === 0)
return false;
else
return !! cond;
});
};

View File

@@ -1,78 +0,0 @@
UI.If = Component.extend({
kind: 'If',
init: function () {
// XXX this probably deserves a better explanation if this code is
// going to stay with us.
this.condition = this.data;
// content doesn't see the condition as `data`
this.data = undefined;
// XXX I guess this means it's kosher to mutate properties
// of a Component during init (but presumably not before
// or after)?
},
render: function () {
var self = this;
return function () {
var condition = getCondition(self);
// `__content` and `__elseContent` are passed by
// the compiler and are *not* emboxed, they are just
// Component kinds.
return condition ? self.__content : self.__elseContent;
};
}
});
// Acts like `!! self.condition()` except:
//
// - Empty array is considered falsy
// - The result is Deps.isolated (doesn't trigger invalidation
// as long as the condition stays truthy or stays falsy
var getCondition = function (self) {
return Deps.isolateValue(function () {
// `condition` is emboxed; it is always a function,
// and it only triggers invalidation if its return
// value actually changes. We still need to isolate
// the calculation of whether it is truthy or falsy
// in order to not re-render if it changes from one
// truthy or falsy value to another.
var cond = self.condition();
// empty arrays are treated as falsey values
if (cond instanceof Array && cond.length === 0)
return false;
else
return !! cond;
});
};
UI.Unless = Component.extend({
kind: 'Unless',
init: function () {
this.condition = this.data;
this.data = undefined;
},
render: function () {
var self = this;
return function () {
var condition = getCondition(self);
return (! condition) ? self.__content : self.__elseContent;
};
}
});
UI.With = Component.extend({
kind: 'With',
init: function () {
this.condition = this.data;
},
render: function () {
var self = this;
return function () {
var condition = getCondition(self);
return condition ? self.__content : self.__elseContent;
};
}
});

View File

@@ -1,11 +1,5 @@
UI.Each = Component.extend({
UI.EachImpl = Component.extend({
typeName: 'Each',
init: function () {
// don't keep `this.data` around so that `{{..}}` skips over this
// component
this.sequence = this.data;
this.data = undefined;
},
render: function (modeHint) {
var self = this;
var content = self.__content;
@@ -27,7 +21,7 @@ UI.Each = Component.extend({
// a method like component.populate(domRange) and one
// like renderStatic() or even renderHTML / renderText.
var parts = _.map(
ObserveSequence.fetch(self.get('sequence')),
ObserveSequence.fetch(self.__sequence()),
function (item) {
return content.withData(function () {
return item;
@@ -75,7 +69,7 @@ UI.Each = Component.extend({
try {
this.observeHandle = ObserveSequence.observe(function () {
return self.get('sequence');
return self.__sequence();
}, {
addedAt: function (id, item, i, beforeId) {
addToCount(1);

View File

@@ -23,7 +23,7 @@ Package.on_use(function (api) {
api.add_files(['attrs.js',
'render.js',
'components.js',
'builtins.js',
'each.js',
'fields.js'
]);