apostrophe
Version:
The Apostrophe Content Management System.
285 lines (258 loc) • 10.2 kB
JavaScript
// This module provides ways to "push" JavaScript calls so that they happen in the
// web browser in a well-managed way.
//
// For calls that should happen for every page load, or for every logged-in page load,
// see the `browserCall` method of this module (`apos.push.browserCall`) as documented
// below.
//
// For calls that should happen only for a specific request (`req`), this module
// extends `req` with a `browserCall` method which takes exactly the same arguments
// as `apos.push.browserCall`, except that there is no `when` argument.
//
// Example:
//
// ```
// req.browserCall('apos.someModule.method(?, ?)', arg1, arg2, ...)
// ```
//
// Each `?` is replaced by a properly JSON-encoded version of the
// next argument.
//
// If you need to pass the name or part of the name of a
// function dynamically, you can use @ to pass an argument
// literally:
//
// ```
// req.browserCall('new @(?)', 'MyConstructor', { options... })
// ```
//
// In addition, the `browserMirrorCall` method provides a way to dynamically
// duplicate the inheritance tree of a server-side moog type for a browser-side
// moog type, so that developers don't have to ask themselves whether a particular
// module bothered to subclass a particular modal, etc. when subclassing further.
var _ = require('@sailshq/lodash');
module.exports = {
alias: 'push',
construct: function(self, options) {
// Make a browserCall method available on every request object,
// which is called like this:
//
// req.browserCall('my.browserSide.method(?, ?)', arg1, arg2, ...)
//
// Each ? is replaced by a properly JSON-encoded version of the
// next argument.
//
// If you need to pass the name or part of the name of a
// function dynamically, you can use @ to pass an argument
// literally:
//
// req.browserCall('new @(?)', 'MyConstructor', { options... })
//
// These calls can be returned as a single block of browser side
// js code by invoking:
//
// req.getBrowserCalls()
//
// Apostrophe automatically does this in renderPage().
//
// The req object is necessary because the context for these
// calls is a single page request. You will typically invoke
// this from a route function, a page loader, or middleware.
//
// See below for a way to push the same call
// on EVERY request, for various classes of users.
self.apos.app.request.browserCall = function(pattern) {
var req = this;
if (!req.aposBrowserCalls) {
req.aposBrowserCalls = [];
}
// Turn arguments into a real array https://developer.mozilla.org/en-US/docs/JavaScript/Reference/Functions_and_function_scope/arguments
var args = Array.prototype.slice.call(arguments);
req.aposBrowserCalls.push({
pattern: pattern,
arguments: args.slice(1)
});
};
// Make a getBrowserCalls method available on every request object,
// which returns JavaScript to implement the browser-side
// JavaScript calls that have been queued via calls to
// req.pushCall(pattern, args...)
self.apos.app.request.getBrowserCalls = function(pattern) {
var req = this;
return self.getBrowserCallsBody(req.aposBrowserCalls || []);
};
self.browserCalls = {};
// Push a browser side JS call that will be invoked "when"
// a particular situation applies. Currently `always` and
// `user` (a logged in user is present) are supported. Any
// `@`s and `?`s in `pattern` are replaced with the remaining arguments
// after `when`. `@` arguments appear literally (useful for
// constructor names) while `?` arguments are JSON-encoded.
//
// Example:
// `apos.push.browserCall('user', 'myObject.addType(?)', typeObject)`
self.browserCall = function(when, pattern /* , arg1, arg2... */) {
if (arguments.length < 2) {
throw new Error('apos.push.browserCall was invoked with only one argument.');
}
var args = Array.prototype.slice.call(arguments);
if (!self.browserCalls[when]) {
self.browserCalls[when] = [];
}
self.browserCalls[when].push({ pattern: pattern, arguments: args.slice(2) });
};
// A convenience wrapper for invoking apos.mirror
// on the browser side, to ensure a client-side
// moog type exists with the same class hierarchy
// as the given object (usually a server-side module).
//
// You can think of this as just passing the object.__meta
// object to apos.mirror on the browser side, although
// we prune it to avoid revealing information about the
// filesystem that doesn't matter on the browser side.
//
// `options` may be omitted. If `options.tool` is present,
// it is appended to the type names being defined, after a hyphen.
// This is useful to define related types, like `apostrophe-pieces-manager-modal`.
// If an `options.substitute` object is present, the type names specified by
// its keys are replaced with the corresponding values. Related types starting with
// `my-` are also substituted without the need to separately specify that.
//
// If `options.stop` is present, mirroring stops when that base class
// is reached (inclusive). The search begins from the deepest subclass.
// `options.stop` is considered AFTER `options.substitute` is applied.
self.browserMirrorCall = function(when, object, options) {
var tool = options && options.tool;
var stop = options && options.stop;
var substitute = (options && options.substitute) || {};
var meta = {
name: object.__meta.name + addSuffix(tool)
};
meta.chain = [];
_.each(object.__meta.chain, function(entry) {
var finalName;
var baseName;
if (self.apos.synth.isMy(entry.name)) {
baseName = self.apos.synth.myToOriginal(entry.name);
var baseFinalName = substitute[baseName] || baseName;
finalName = self.apos.synth.originalToMy(baseFinalName);
} else {
baseName = entry.name;
finalName = substitute[baseName] || baseName;
}
meta.chain.push({ name: finalName + addSuffix(tool) });
if (stop && (finalName === stop)) {
// Superclasses of the stop type should be chopped off the list
meta.chain.splice(0, meta.chain.length - 1);
}
});
return self.browserCall(when, 'apos.mirror(?)', meta);
function addSuffix(tool) {
if (!tool) {
return '';
}
return '-' + tool;
}
};
// Returns browser-side JavaScript to make the calls
// queued up for the particular situation (`always`
// or `user`).
self.getBrowserCalls = function(when) {
var s = self.getBrowserCallsBody(self.browserCalls[when] || []);
return s;
};
// Part of the implementation of req.getBrowserCalls and
// apos.push.getBrowserCalls.
//
// Turn any number of call objects like this:
// `[ { pattern: @.func(?), arguments: [ 'myFn', { age: 57 } ] } ]`
//
// Into javascript source code like this:
//
// `myFn.func({ age: 57 });`
//
// `... next call here ...`
//
// Suitable to be emitted inside a script tag.
//
// Note that `?` JSON-encodes an argument, while `@` inserts it literally.
self.getBrowserCallsBody = function(calls) {
return _.map(calls, function(call) {
var code = ' ';
var pattern = call.pattern;
var n = 0;
var from = 0;
while (true) {
var qat = pattern.substr(from).search(/[?@]/);
if (qat !== -1) {
qat += from;
code += pattern.substr(from, qat - from);
if (pattern.charAt(qat) === '?') {
// ? inserts an argument JSON-encoded
try {
code += self.apos.templates.jsonForHtml(call.arguments[n++]);
} catch (e) {
self.apos.utils.error(call.arguments);
throw e;
}
} else {
// @ inserts an argument literally, unquoted
code += call.arguments[n++];
}
from = qat + 1;
} else {
code += pattern.substr(from);
break;
}
}
code += ";";
return code;
}).join("\n");
};
self.addHelpers({
// Invoke browser-side javascript calls published
// via `req.browserCall` during the current request.
// This is for use when you are implementing an AJAX refresh
// of part of the page but you need the benefit of such calls
// (for instance: apostrophe-places map calls).
newBrowserCalls: function() {
var req = self.apos.templates.contextReq;
return self.apos.templates.safe(
'<script type="text/javascript">\n' +
' if (window.apos) {\n' +
' ' + req.getBrowserCalls() + '\n' +
' }\n' +
'</script>\n'
);
}
});
// If lean: true is active for assets, modify the behavior as follows:
//
// * browser calls pushed for a particular request for "anon" don't
// go anywhere at all (they are associated with legacy js that won't
// be there to receive them).
//
// * browser calls pushed for the "always" scene in general are
// still pushed, but only for the "user" scene where the necessary
// code exists.
var superReqGetBrowserCalls = self.apos.app.request.getBrowserCalls;
self.apos.app.request.getBrowserCalls = function() {
if (self.apos.assets.options.lean) {
var scene = this.scene || (this.user ? 'user' : 'anon');
if (scene === 'anon') {
return '';
}
}
return superReqGetBrowserCalls.apply(this, arguments);
};
var superBrowserCall = self.browserCall;
self.browserCall = function(when, pattern /* , arg1, arg2... */) {
if (self.apos.assets.options.lean) {
if (arguments[0] === 'always') {
arguments[0] = 'user';
}
}
return superBrowserCall.apply(self, arguments);
};
}
};