can
Version:
MIT-licensed, client-side, JavaScript framework that makes building rich web applications easy.
770 lines (700 loc) • 23.4 kB
JavaScript
// # can/view/view.js
// -------
// `can.view`
// _Templating abstraction._
// can.view loads templates based on a registered type, and given a set of data, returns a document fragment
// from the template engine's rendering method
//
steal('can/util', function (can) {
var isFunction = can.isFunction,
makeArray = can.makeArray,
// Used for hookup `id`s.
hookupId = 1;
// internal utility methods
// ------------------------
// ##### makeRenderer
/**
* @hide
* Rendering function factory method
* @param textRenderer
* @return {renderer}
*/
var makeRenderer = function(textRenderer) {
var renderer = function() {
return $view.frag(textRenderer.apply(this, arguments));
};
renderer.render = function() {
return textRenderer.apply(textRenderer, arguments);
};
return renderer;
};
// ##### checkText
// Makes sure there's a template, if not, have `steal` provide a warning.
var checkText = function (text, url) {
if (!text.length) {
// _removed if not used as a steal module_
//!steal-remove-start
can.dev.log("can/view/view.js: There is no template or an empty template at " + url);
//!steal-remove-end
throw new Error("can.view: No template or empty template:" + url);
}
};
// ##### get
// get a deferred renderer for provided url
/**
* @hide
* @function get
* @param {String | Object} obj url string or object with url property
* @param {Boolean} async If the ajax request should be asynchronous.
* @return {can.Deferred} a `view` renderer deferred.
*/
var getRenderer = function (obj, async) {
// If `obj` already is a renderer function just resolve a Deferred with it
if(isFunction(obj)) {
var def = can.Deferred();
return def.resolve(obj);
}
var url = typeof obj === 'string' ? obj : obj.url,
suffix = (obj.engine && '.' + obj.engine) || url.match(/\.[\w\d]+$/),
type,
// If we are reading a script element for the content of the template,
// `el` will be set to that script element.
el,
// A unique identifier for the view (used for caching).
// This is typically derived from the element id or
// the url for the template.
id;
//If the url has a #, we assume we want to use an inline template
//from a script element and not current page's HTML
if (url.match(/^#/)) {
url = url.substr(1);
}
// If we have an inline template, derive the suffix from the `text/???` part.
// This only supports `<script>` tags.
if (el = document.getElementById(url)) {
suffix = '.' + el.type.match(/\/(x\-)?(.+)/)[2];
}
// If there is no suffix, add one.
if (!suffix && !$view.cached[url]) {
url += suffix = $view.ext;
}
// if the suffix was derived from the .match() operation, pluck out the first value
if (can.isArray(suffix)) {
suffix = suffix[0];
}
// Convert to a unique and valid id.
id = $view.toId(url);
// If an absolute path, use `steal`/`require` to get it.
// You should only be using `//` if you are using an AMD loader like `steal` or `require` (not almond).
if (url.match(/^\/\//)) {
url = url.substr(2);
url = !window.steal ?
url :
steal.config()
.root.mapJoin("" + steal.id(url));
}
// Localize for `require` (not almond)
if (window.require) {
if (require.toUrl) {
url = require.toUrl(url);
}
}
// Set the template engine type.
type = $view.types[suffix];
// If it is cached,
if ($view.cached[id]) {
// Return the cached deferred renderer.
return $view.cached[id];
// Otherwise if we are getting this from a `<script>` element.
} else if (el) {
// Resolve immediately with the element's `innerHTML`.
return $view.registerView(id, el.innerHTML, type);
} else {
// Make an ajax request for text.
var d = new can.Deferred();
can.ajax({
async: async,
url: url,
dataType: 'text',
error: function (jqXHR) {
checkText('', url);
d.reject(jqXHR);
},
success: function (text) {
// Make sure we got some text back.
checkText(text, url);
$view.registerView(id, text, type, d);
}
});
return d;
}
};
// ##### getDeferreds
// Gets an `array` of deferreds from an `object`.
// This only goes one level deep.
/**
* @hide
* @param {Object|can.Deferred} data
* @return {Array} deferred objects
*/
var getDeferreds = function (data) {
var deferreds = [];
// pull out deferreds
if (can.isPromise(data)) {
return [data];
} else {
for (var prop in data) {
if (can.isPromise(data[prop])) {
deferreds.push(data[prop]);
}
}
}
return deferreds;
};
// ##### usefulPart
// Gets the useful part of a resolved deferred.
// When a jQuery.when is resolved, it returns an array to each argument.
// Reference ($.when)[https://api.jquery.com/jQuery.when/]
// This is for `model`s and `can.ajax` that resolve to an `array`.
/**
* @hide
* @function usefulPart
* @param {Array|*} resolved
* @return {*}
*/
var usefulPart = function (resolved) {
return can.isArray(resolved) && resolved[1] === 'success' ? resolved[0] : resolved;
};
// #### can.view
//defines $view for internal use, can.template for backwards compatibility
/**
* @add can.view
*/
var $view = can.view = can.template = function (view, data, helpers, callback) {
// If helpers is a `function`, it is actually a callback.
if (isFunction(helpers)) {
callback = helpers;
helpers = undefined;
}
// Render the view as a fragment
return $view.renderAs("fragment",view, data, helpers, callback);
};
// can.view methods
// --------------------------
can.extend($view, {
// ##### frag
// creates a fragment and hooks it up all at once
/**
* @function can.view.frag frag
* @parent can.view.static
*/
frag: function (result, parentNode) {
return $view.hookup($view.fragment(result), parentNode);
},
// #### fragment
// this is used internally to create a document fragment, insert it,then hook it up
fragment: function (result) {
return can.frag(result, document);
},
// ##### toId
// Convert a path like string into something that's ok for an `element` ID.
toId: function (src) {
return can.map(src.toString()
.split(/\/|\./g), function (part) {
// Dont include empty strings in toId functions
if (part) {
return part;
}
})
.join('_');
},
// ##### toStr
// convert argument to a string
toStr: function(txt){
return txt == null ? "" : ""+txt;
},
// ##### hookup
// attach the provided `fragment` to `parentNode`
/**
* @hide
* hook up a fragment to its parent node
* @param fragment
* @param parentNode
* @return {*}
*/
hookup: function (fragment, parentNode) {
var hookupEls = [],
id,
func;
// Get all `childNodes`.
can.each(fragment.childNodes ? can.makeArray(fragment.childNodes) : fragment, function (node) {
if (node.nodeType === 1) {
hookupEls.push(node);
hookupEls.push.apply(hookupEls, can.makeArray(node.getElementsByTagName('*')));
}
});
// Filter by `data-view-id` attribute.
can.each(hookupEls, function (el) {
if (el.getAttribute && (id = el.getAttribute('data-view-id')) && (func = $view.hookups[id])) {
func(el, parentNode, id);
delete $view.hookups[id];
el.removeAttribute('data-view-id');
}
});
return fragment;
},
// `hookups` keeps list of pending hookups, ie fragments to attach to a parent node
/**
* @property hookups
* @hide
* A list of pending 'hookups'
*/
hookups: {},
// `hook` factory method for hookup function inserted into templates
// hookup functions are called after the html is rendered to the page
// only implemented by EJS templates.
/**
* @description Create a hookup to insert into templates.
* @function can.view.hook hook
* @parent can.view.static
* @signature `can.view.hook(callback)`
* @param {Function} callback A callback function to be called with the element.
*
* @body
* Registers a hookup function that can be called back after the html is
* put on the page. Typically this is handled by the template engine. Currently
* only EJS supports this functionality.
*
* var id = can.view.hook(function(el){
* //do something with el
* }),
* html = "<div data-view-id='"+id+"'>"
* $('.foo').html(html);
*/
hook: function (cb) {
$view.hookups[++hookupId] = cb;
return ' data-view-id=\'' + hookupId + '\'';
},
/**
* @hide
* @property {Object} can.view.cached view
* @parent can.view
* Cached are put in this object
*/
cached: {},
cachedRenderers: {},
// cache view templates resolved via XHR on the client
/**
* @property {Boolean} can.view.cache cache
* @parent can.view.static
* By default, views are cached on the client. If you'd like the
* the views to reload from the server, you can set the `cache` attribute to `false`.
*
* //- Forces loads from server
* can.view.cache = false;
*
*/
cache: true,
// ##### register
// given an info object, register a template type
// different templating solutions produce strings or document fragments via their renderer function
/**
* @function can.view.register register
* @parent can.view.static
* @description Register a templating language.
* @signature `can.view.register(info)`
* @param {{}} info Information about the templating language.
* @option {String} plugin The location of the templating language's plugin.
* @option {String} suffix Files with this suffix will use this templating language's plugin by default.
* @option {function} renderer A function that returns a function that, given data, will render the template with that data.
* The __renderer__ function receives the id of the template and the text of the template.
* @option {function} script A function that returns the string form of the processed template.
*
* @body
* Registers a template engine to be used with
* view helpers and compression.
*
* ## Example
*
* ```
* can.View.register({
* suffix : "tmpl",
* plugin : "jquery/view/tmpl",
* renderer: function( id, text ) {
* return function(data){
* return jQuery.render( text, data );
* }
* },
* script: function( id, text ) {
* var tmpl = can.tmpl(text).toString();
* return "function(data){return ("+
* tmpl+
* ").call(jQuery, jQuery, data); }";
* }
* })
* ```
*/
register: function (info) {
this.types['.' + info.suffix] = info;
// _removed if not used as a steal module_
//!steal-remove-start
if ( typeof window !== "undefined" && window.steal && steal.type ) {
steal.type(info.suffix + " view js", function (options, success, error) {
var type = $view.types["." + options.type],
id = $view.toId(options.id + '');
options.text = type.script(id, options.text);
success();
});
}
//!steal-remove-end
can[info.suffix] = $view[info.suffix] = function (id, text) {
var renderer,
renderFunc;
// If there is no text, assume id is the template text, so return a nameless renderer.
if (!text) {
renderFunc = function(){
if(!renderer){
// if the template has a fragRenderer already, just return that.
if(info.fragRenderer) {
renderer = info.fragRenderer(null, id);
} else {
renderer = makeRenderer(info.renderer(null, id));
}
}
return renderer.apply(this, arguments);
};
renderFunc.render = function() {
var textRenderer = info.renderer(null, id);
return textRenderer.apply(textRenderer, arguments);
};
return renderFunc;
}
var registeredRenderer = function(){
if(!renderer){
if(info.fragRenderer) {
renderer = info.fragRenderer(id, text);
} else {
renderer = info.renderer(id, text);
}
}
return renderer.apply(this, arguments);
};
if(info.fragRenderer) {
return $view.preload( id, registeredRenderer );
} else {
return $view.preloadStringRenderer(id, registeredRenderer);
}
};
},
//registered view types
types: {},
/**
* @property {String} can.view.ext ext
* @parent can.view.static
* The default suffix to use if none is provided in the view's url.
* This is set to `.ejs` by default.
*
* // Changes view ext to 'txt'
* can.view.ext = 'txt';
*
*/
ext: ".ejs",
/**
* Returns the text from a script tag
* @hide
* @param {Object} type
* @param {Object} id
* @param {Object} src
*/
registerScript: function (type, id, src) {
return 'can.view.preloadStringRenderer(\'' + id + '\',' + $view.types['.' + type].script(id, src) + ');';
},
/**
* @hide
* Called by a production script to pre-load a fragment renderer function
* into the view cache.
* @param {String} id
* @param {Function} renderer
*/
preload: function (id, renderer) {
var def = $view.cached[id] = new can.Deferred()
.resolve(function (data, helpers) {
return renderer.call(data, data, helpers);
});
// set cache references (otherwise preloaded recursive views won't recurse properly)
def.__view_id = id;
$view.cachedRenderers[id] = renderer;
return renderer;
},
/**
* @hide
* Called by a production script to pre-load a string renderer function
* into the view cache.
* @param id
* @param stringRenderer
* @return {*}
*/
preloadStringRenderer: function(id, stringRenderer) {
return this.preload(id, makeRenderer(stringRenderer) );
},
// #### renderers
// ---------------
// can.view's primary purpose is to load templates (from strings or filesystem) and render them
//
// can.view supports two different forms of rendering systems
//
// mustache templates return a string based rendering function
// stache (or other fragment based templating systems) return a document fragment, so 'hookup' steps are not required
//
// ##### render
//
/**
* @function can.view.render render
* @parent can.view.static
* @description Render a template.
* @signature `can.view.render(template[, callback])`
* @param {String|Object} view The path of the view template or a view object.
* @param {Function} [callback] A function executed after the template has been processed.
* @return {Function|can.Deferred} A renderer function to be called with data and helpers
* or a Deferred that resolves to a renderer function.
*
* @signature `can.view.render(template, data[, [helpers,] callback])`
* @param {String|Object} view The path of the view template or a view object.
* @param {Object} [data] The data to populate the template with.
* @param {Object.<String, function>} [helpers] Helper methods referenced in the template.
* @param {Function} [callback] A function executed after the template has been processed.
* @param {NodeList} nodelist parent nodelist to register partial template contents with
* @return {String|can.Deferred} The template with interpolated data in string form
* or a Deferred that resolves to the template with interpolated data.
*
* @body
* `can.view.render(view, [data], [helpers], callback)` returns the rendered markup produced by the corresponding template
* engine as String. If you pass a deferred object in as data, render returns
* a deferred resolving to the rendered markup.
*
* `can.view.render` is commonly used for sub-templates.
*
* ## Example
*
* _welcome.ejs_ looks like:
*
* <h1>Hello <%= hello %></h1>
*
* Render it to a string like:
*
* can.view.render("welcome.ejs",{hello: "world"})
* //-> <h1>Hello world</h1>
*
* ## Use as a Subtemplate
*
* If you have a template like:
*
* <ul>
* <% list(items, function(item){ %>
* <%== can.view.render("item.ejs",item) %>
* <% }) %>
* </ul>
*
* ## Using renderer functions
*
* If you only pass the view path, `can.view will return a renderer function that can be called with
* the data to render:
*
* var renderer = can.view.render("welcome.ejs");
* // Do some more things
* renderer({hello: "world"}) // -> Document Fragment
*
*/
//call `renderAs` with a hardcoded string, as view.render
// always operates against resolved template files or hardcoded strings
render: function (view, data, helpers, callback, nodelist) {
return can.view.renderAs("string",view, data, helpers, callback, nodelist);
},
// ##### renderTo
//
/**
* @hide
* @function renderTo
* @param {String} format
* @param {Function} renderer
* @param data
* @param {Object} helpers helper methods for this template
* @param {NodeList} nodelist parent nodelist to register partial template contents with
* @return {*}
*/
renderTo: function(format, renderer, data, helpers, nodelist){
return (format === "string" && renderer.render ? renderer.render : renderer)(data, helpers, nodelist);
},
/**
* @hide
*
* @param format
* @param view
* @param data
* @param helpers
* @param callback
* @param nodelist
* @return {*}
*/
renderAs: function (format, view, data, helpers, callback, nodelist) {
// if callback has expression prop its actually the nodelist
if (callback !== undefined && typeof callback.expression === 'string') {
nodelist = callback;
callback = undefined;
}
// If helpers is a `function`, it is actually a callback.
if (isFunction(helpers)) {
callback = helpers;
helpers = undefined;
}
// See if we got passed any deferreds.
var deferreds = getDeferreds(data);
var deferred, dataCopy, async, response;
if (deferreds.length) {
// Does data contain any deferreds?
// The deferred that resolves into the rendered content...
deferred = new can.Deferred();
dataCopy = can.extend({}, data);
// Add the view request to the list of deferreds.
deferreds.push(getRenderer(view, true));
// Wait for the view and all deferreds to finish...
can.when.apply(can, deferreds)
.then(function (resolved) {
// Get all the resolved deferreds.
var objs = makeArray(arguments),
// Renderer is the last index of the data.
renderer = objs.pop(),
// The result of the template rendering with data.
result;
// Make data look like the resolved deferreds.
if (can.isPromise(data)) {
dataCopy = usefulPart(resolved);
} else {
// Go through each prop in data again and
// replace the defferreds with what they resolved to.
for (var prop in data) {
if (can.isPromise(data[prop])) {
dataCopy[prop] = usefulPart(objs.shift());
}
}
}
// Get the rendered result.
result = can.view.renderTo(format, renderer, dataCopy, helpers, nodelist);
// Resolve with the rendered view.
deferred.resolve(result, dataCopy);
// If there's a `callback`, call it back with the result.
if (callback) {
callback(result, dataCopy);
}
}, function () {
deferred.reject.apply(deferred, arguments);
});
// Return the deferred...
return deferred;
} else {
// If there's a `callback` function
async = isFunction(callback);
// get is called async but in
// ff will be async so we need to temporarily reset
deferred = can.__notObserve(getRenderer)(view, async);
// If we are `async`...
if (async) {
// Return the deferred
response = deferred;
// And fire callback with the rendered result.
deferred.then(function (renderer) {
callback(data ? can.view.renderTo(format, renderer, data, helpers, nodelist) : renderer);
});
} else {
// if the deferred is resolved, call the cached renderer instead
// this is because it's possible, with recursive deferreds to
// need to render a view while its deferred is _resolving_. A _resolving_ deferred
// is a deferred that was just resolved and is calling back it's success callbacks.
// If a new success handler is called while resoliving, it does not get fired by
// jQuery's deferred system. So instead of adding a new callback
// we use the cached renderer.
// We also add __view_id on the deferred so we can look up it's cached renderer.
// In the future, we might simply store either a deferred or the cached result.
if (deferred.state() === 'resolved' && deferred.__view_id) {
var currentRenderer = $view.cachedRenderers[deferred.__view_id];
return data ? can.view.renderTo(format, currentRenderer, data, helpers, nodelist) : currentRenderer;
} else {
// Otherwise, the deferred is complete, so
// set response to the result of the rendering.
deferred.then(function (renderer) {
response = data ? can.view.renderTo(format, renderer, data, helpers, nodelist) : renderer;
});
}
}
return response;
}
},
/**
* @hide
* Registers a view with `cached` object. This is used
* internally by this class and Mustache to hookup views.
* @param {String} id
* @param {String} text
* @param {String} type
* @param {can.Deferred} def
*/
registerView: function (id, text, type, def) {
// Get the renderer function.
var info = (typeof type === "object" ? type : $view.types[type || $view.ext]),
renderer;
if(info.fragRenderer) {
renderer = info.fragRenderer(id, text);
} else {
renderer = makeRenderer( info.renderer(id, text) );
}
def = def || new can.Deferred();
// Cache if we are caching.
if ($view.cache) {
$view.cached[id] = def;
def.__view_id = id;
$view.cachedRenderers[id] = renderer;
}
// Return the objects for the response's `dataTypes`
// (in this case view).
return def.resolve(renderer);
},
// Returns a function that automatically converts all computes passed to it
simpleHelper: function(fn) {
return function() {
var realArgs = [];
var fnArgs = arguments;
can.each(fnArgs, function(val, i) {
if (i <= fnArgs.length) {
while (val && val.isComputed) {
val = val();
}
realArgs.push(val);
}
});
return fn.apply(this, realArgs);
};
}
});
// _removed if not used as a steal module_
//!steal-remove-start
if ( typeof window !== "undefined" && window.steal && steal.type) {
//when being used as a steal module, add a new type for 'view' that runs
// `can.view.preloadStringRenderer` with the loaded string/text for the dependency.
steal.type("view js", function (options, success, error) {
var type = $view.types["." + options.type],
id = $view.toId(options.id);
/**
* @hide
* should return something like steal("dependencies",function(EJS){
* return can.view.preload("ID", options.text)
* })
*/
var dependency = type.plugin || 'can/view/' + options.type,
preload = type.fragRenderer ? "preload" : "preloadStringRenderer";
options.text = 'steal(\'can/view\',\'' + dependency + '\',function(can){return ' + 'can.view.'+preload+'(\'' + id + '\',' + options.text + ');\n})';
success();
});
}
//!steal-remove-end
return can;
});