apostrophe
Version:
Apostrophe is a user-friendly content management system. You'll need more than this core module. See apostrophenow.org to get started.
1,263 lines (1,172 loc) • 54.5 kB
JavaScript
var path = require('path');
var extend = require('extend');
var _ = require('lodash');
var sanitize = require('validator').sanitize;
var async = require('async');
var fs = require('fs');
// JS minifier and optimizer
var uglifyJs = require('uglify-js');
// CSS minifier https://github.com/GoalSmashers/clean-css
var cleanCss = require('clean-css');
// LESS CSS compiler
var less = require('less');
var moment = require('moment');
var glob = require('glob');
/**
* assets
* @augments Augments the apos object with methods, routes and
* properties supporting the serving of specific assets (CSS, templates and
* browser-side JavaScript) required by Apostrophe.
* @see static
*/
module.exports = {
/**
* Augment apos object with resources necessary prior to init() call
* @param {Object} self The apos object
*/
construct: function(self) {
// Default stylesheet requirements
// TODO: lots of override options
var stylesheets = [
// Has a subdirectory of relative image paths so give it a folder
{ name: "_site", when: 'always' },
{ name: "_user", when: 'user' },
// Load this *after* the "content" stylesheet which can contain fonts.
// Font imports must come first. It's a pain.
{ name: "jquery-ui-darkness/jquery-ui-darkness", when: 'always' }
];
// Default browser side script requirements
// TODO: lots of override options
var scripts = [
// VENDOR DEPENDENCIES
// polyfill setImmediate, much faster than setTimeout(fn, 0)
{ name: 'vendor/setImmediate', when: 'always' },
// For elegant, cross-browser functional-style programming
{ name: 'vendor/lodash.compat', when: 'always' },
// For async code without tears
{ name: 'vendor/async', when: 'always' },
// For everything DOM-related
{ name: 'vendor/jquery', when: 'always' },
// For parsing query parameters browser-side
{ name: 'vendor/jquery-url-parser', when: 'always' },
// For blueimp uploader, drag and drop reordering of anything, datepicker
// & autocomplete
{ name: 'vendor/jquery-ui', when: 'always' },
// For the RTE
{ name: 'vendor/jquery-hotkeys', when: 'user' },
// Graceful fallback for older browsers for jquery fileupload
{ name: 'vendor/jquery.iframe-transport', when: 'user' },
// Spiffy multiple file upload
{ name: 'vendor/jquery.fileupload', when: 'user' },
// imaging cropping plugin
{ name: 'vendor/jquery.Jcrop.min', when: 'user' },
// textchange event, detects actual typing activity, not just focus change
{ name: 'vendor/jquery-textchange', when: 'always' },
// select element enhancement plugin
{ name: 'vendor/selectize', when: 'always' },
// Set, get and delete cookies in browser-side JavaScript
{ name: 'vendor/jquery.cookie', when: 'always' },
{ name: 'vendor/jquery.findSafe', when: 'user' },
{ name: 'vendor/sluggo', when: 'user' },
// i18n helper
{ name: 'vendor/polyglot', when: 'always' },
// Scroll things into view, even if they are in a scrolling
// container which itself needs to be scrolled into view or
// whatever, it's pretty great:
//
// http://erraticdev.blogspot.com/2011/02/jquery-scroll-into-view-plugin-with.html
//
// (Note recent comments, it's actively maintained). -Tom
{ name: 'vendor/jquery.scrollintoview', when: 'user' },
// PUNKAVE-MAINTAINED, GENERAL PURPOSE JQUERY PLUGINS
{ name: 'vendor/jquery.get-outer-html', when: 'always' },
{ name: 'vendor/jquery.find-by-name', when: 'always' },
{ name: 'vendor/jquery.projector', when: 'always' },
{ name: 'vendor/jquery.bottomless', when: 'always' },
{ name: 'vendor/jquery.selective', when: 'always' },
{ name: 'vendor/jquery.images-ready', when: 'always' },
{ name: 'vendor/jquery.radio', when: 'always' },
{ name: 'vendor/jquery.json-call', when: 'always' },
//N.B. This is version 0.5 of Joel's lister.js
{ name: 'vendor/jquery.lister', when: 'always' },
// APOSTROPHE CORE JS
// Viewers for standard content types
{ name: 'content', when: 'always' },
// Editing functionality
{ name: 'user', when: 'user' },
{ name: 'permissions', when: 'user' },
{ name: 'widgets/editors/buttons', when: 'user' },
{ name: 'widgets/editors/code', when: 'user' },
{ name: 'widgets/editors/files', when: 'user' },
{ name: 'widgets/editors/html', when: 'user' },
{ name: 'widgets/editors/marquee', when: 'user' },
{ name: 'widgets/editors/pullquote', when: 'user' },
{ name: 'widgets/editors/slideshow', when: 'user' },
{ name: 'widgets/editors/video', when: 'user' },
{ name: 'widgets/editors/embed', when: 'user' },
{ name: 'widgets/editors/widget', when: 'user' },
{ name: 'annotator', when: 'user' },
{ name: 'mediaLibrary', when: 'user' },
{ name: 'tagEditor', when: 'user' }
];
// Templates pulled into the page by the aposTemplates() Express local
// These are typically hidden at first by CSS and cloned as needed by jQuery
var templates = [
{ name: 'slideshowEditor', when: 'user' },
{ name: 'buttonsEditor', when: 'user' },
{ name: 'marqueeEditor', when: 'user' },
{ name: 'filesEditor', when: 'user' },
{ name: 'pullquoteEditor', when: 'user' },
{ name: 'videoEditor', when: 'user' },
{ name: 'embedEditor', when: 'user' },
{ name: 'codeEditor', when: 'user' },
{ name: 'htmlEditor', when: 'user' },
{ name: 'cropEditor', when: 'user' },
{ name: 'tableEditor', when: 'user' },
{ name: 'linkEditor', when: 'user' },
{ name: 'fileAnnotator', when: 'user' },
{ name: 'tagEditor', when: 'user' },
{ name: 'loginWindow', when: 'always' },
{ name: 'logoutWindow', when: 'user' },
{ name: 'notification', when: 'user' },
{ name: 'notificationsContainer', when: 'user' }
];
// Full paths to assets as computed by pushAsset
self._assets = { stylesheets: [], scripts: [], templates: [] };
// Maps web paths of asset modules to filesystem paths, used by
// endAssets
self._assetPaths = {
// The apostrophe module itself doesn't call the asset mixin that populates
// the rest of this object, but does need a symlink for its assets
'/modules/apos': path.dirname(__dirname)
};
self._assetTypes = {
script: {
ext: 'js',
fs: 'public/js',
web: 'js',
key: 'scripts',
serve: 'web'
},
stylesheet: {
ext: 'css',
fs: 'public/css',
web: 'css',
alternate: 'less',
key: 'stylesheets',
serve: 'web'
},
template: {
fs: 'views',
key: 'templates',
serve: 'fs',
ext: 'html'
}
};
// `self.pushAsset('stylesheet', 'foo', { dir: __dirname, web: '/apos-mymodule', when: 'always' })` will preload
// `/apos-mymodule/css/foo.css` at all times.
//
// `self.pushAsset('script', 'foo', { dir: __dirname, web: '/apos-mymodule', when: 'user' })` will preload
// `/apos-mymodule/js/foo.js` only when a user is logged in.
//
// `self.pushAsset('template', 'foo', { dir: __dirname })` will render
// the partial `{__dirname}/views/foo.html` at the bottom of the body
// (`self.partial` will take care of adding the extension). Note
// that 'web' is not used for templates.
//
// If you wish you may pass `options` as the second argument as long
// as you include a `name` property in `options`.
//
// You may also write:
// `self.pushAsset('template', function() { foo })`
//
// Which allows you to render the template in your own context and is typically
// the easier way when pushing a template from a module like apostrophe-snippets.
//
// You may pass data to the template via the `data` option.
//
// The fs and web options default to `__dirname` and `/apos` for easy use in
// the apostrophe module itself.
//
// Other modules typically have a wrapper method that passes them correctly
// for their needs.
//
// You must pass BOTH fs and web for a stylesheet or script. This allows
// minification, LESS compilation that is aware of relative base paths, etc.
// fs should be the PARENT of the public folder, not the public folder itself.
//
// It is acceptable to push an asset more than once. Only one copy is sent, at
// the earliest point requested.
//
// Returns true if an acceptable source file or function for the asset
// exists, otherwise false.
self.pushAsset = function(type, name, options) {
var _fs, web, when;
// Support just 2 arguments with the name as a property
if (typeof(name) === 'object') {
options = name;
name = name.name;
}
if (typeof(options) === 'string') {
// Support old order of parameters
_fs = options;
options = undefined;
web = arguments[3];
}
if (options) {
_fs = options.fs;
web = options.web;
when = options.when || 'always';
} else {
// bc
when = 'always';
options = {};
}
// Careful with the defaults on this, '' is not false for this purpose
if (typeof(_fs) !== 'string') {
_fs = __dirname + '/..';
}
if (typeof(web) !== 'string') {
web = '/modules/apos';
}
var data = options ? options.data : undefined;
if (typeof(name) === 'function') {
self._assets[self._assetTypes[type].key].push({ call: name, data: data, when: when });
return true;
}
var fileDir = _fs + '/' + self._assetTypes[type].fs;
var webDir = web + '/' + self._assetTypes[type].web;
var filePath = fileDir + '/' + name;
if (self._assetTypes[type].ext) {
filePath += '.' + self._assetTypes[type].ext;
}
var webPath = webDir + '/' + name;
if (self._assetTypes[type].ext) {
webPath += '.' + self._assetTypes[type].ext;
}
var exists = fs.existsSync(filePath);
if (self._assetTypes[type].alternate && fs.existsSync(filePath.replace(/\.\w+$/, '.' + self._assetTypes[type].alternate))) {
exists = true;
} else if (fs.existsSync(filePath)) {
exists = true;
}
if (exists) {
self._assets[self._assetTypes[type].key].push({ file: filePath, web: webPath, data: data, preshrunk: options.preshrunk, when: when, minify: options.minify });
}
return exists;
};
self._endAssetsCalled = false;
self._purgeExcept = function(pattern, exceptSubstring) {
var old = glob.sync(self.options.rootDir + '/public/' + pattern);
// Purge leftover masters from old asset generations. Do
// not remove the current generation if it already exists.
_.each(old, function(file) {
if (file.indexOf(exceptSubstring) === -1) {
try {
fs.unlinkSync(file);
} catch (e) {
// This is nonfatal, probably just a race with
// another process to remove the same file.
}
}
});
};
// You must call `apos.endAssets` when you are through pushing
// assets. This is necessary because the LESS
// compiler is no longer asynchronous, so we can't
// wait for aposStylesheet calls in Nunjucks to
// do the compilation.
//
// You may change the list of available scenes from just
// `anon` and `user` via the `scenes` option when initializing
// Apostrophe. Our official modules are only concerned with those two cases.
// Assets pushed with `when` set to 'always' are
// deployed in both scenes.
//
// Typically `apostrophe-site` calls this for you.
self.endAssets = function(callback) {
if (arguments.length === 2) {
// bc with the two-argument version, which is deprecated
callback = arguments[1];
}
// Emit an event so that any module that is waiting
// for other modules to initialize knows this is the last
// possible chance to push an asset
self.emit('beforeEndAssets');
self._endAssetsCalled = true;
// Create symbolic links in /modules so that our web paths can be
// served by a static server like nginx
if (!self.options.rootDir) {
return callback('You must set the rootDir option when configuring apostrophe (hint: stop calling apos.init yourself and just use apostrophe-site)');
}
// Name of both folder and extension in
// public/ for this type of asset
var typeMap = {
scripts: 'js',
stylesheets: 'css'
};
return async.series({
linkModules: function(callback) {
if (!fs.existsSync(self.options.rootDir + '/public/modules')) {
fs.mkdirSync(self.options.rootDir + '/public/modules');
}
_.each(self._assetPaths, function(fsPath, web) {
var from = self.options.rootDir + '/public' + web;
var to = fsPath + '/public';
if (fs.existsSync(to)) {
self.relinkAssetFolder(from, to);
}
});
return callback(null);
},
buildLessMasters: function(callback) {
self._lessMasters = {};
// Compile all LESS files as one. This is awesome because it allows
// mixins to be shared between modules for better code reuse. It also
// allows you to redefine mixins in a later module; if you do so, they
// are retroactive to the very first use of the mixin. So apostrophe-ui-2
// can alter decisions made in the apostrophe module, for instance.
return self.forAllAssetScenesAndUpgrades(function(scene, callback) {
var base = '/css/master-' + scene + '-';
self._purgeExcept(base + '*', '-' + self._generation);
var masterWeb = base + self._generation + '.less';
var masterFile = self.options.rootDir + '/public' + masterWeb;
var stylesheets = self.filterAssets(self._assets.stylesheets, scene, true);
// Avoid race conditions, if apostrophe:generation created
// the file already leave it alone
if (!fs.existsSync(masterFile)) {
fs.writeFileSync(masterFile, _.map(stylesheets, function(stylesheet) {
// Cope with the way we push .css but actually write .less
// because of the middleware. TODO: think about killing that
var importName = stylesheet.web.replace('.css', '.less');
if (!fs.existsSync(self.options.rootDir + '/public' + importName)) {
importName = stylesheet.web;
}
// For import what we need is a relative path which will work on
// the filesystem too thanks to the symbolic links for modules
var relPath = path.relative(path.dirname(masterWeb), importName);
return '@import \'' + relPath + '\';';
}).join("\n"));
}
self._lessMasters[scene] = {
// The nature of the LESS middleware is that it expects you to
// request a CSS file and uses LESS to render it if available
file: masterFile.replace('.less', '.css'),
web: masterWeb.replace('.less', '.css')
};
return callback(null);
}, callback);
},
minify: function(callback) {
self._minified = {};
if (!self.options.minify) {
// Just use the LESS middleware and direct access to JS
// for dev
return callback(null);
}
var minifiers = {
stylesheets: self.minifyStylesheet,
scripts: self.minifyScript
};
var needed = false;
return self.forAllAssetScenesAndUpgrades(function(scene, callback) {
return async.eachSeries([ 'stylesheets', 'scripts' ], function(type, callback) {
// "Transition" scenes like anon.user cannot be
// computed safely for stylesheets because LESS
// mixins defined in files already sent would not
// be visible to the new files. So don't try to
// calculate those, and in /apos/upgrade-scene
// we won't try to use them either. -Tom
if ((type === 'stylesheets') && scene.match(/\./)) {
return callback(null);
}
self._purgeExcept('/apos-minified/' + scene + '-*.' + typeMap[type], '-' + self._generation);
var file = self.options.rootDir + '/public/apos-minified/' + scene + '-' + self._generation + '.' + typeMap[type];
if (fs.existsSync(file)) {
// Someone has already compiled it for the
// current deployment's asset generation!
// No startup delay! Booyeah!
self._minified[scene] = self._minified[scene] || {};
self._minified[scene][type] = fs.readFileSync(file, 'utf8');
return setImmediate(callback);
}
if (!needed) {
needed = true;
console.log('MINIFYING, this may take a minute...');
}
return self.minifySceneAssetType(scene, type, minifiers[type], callback);
}, callback);
}, function(err) {
if (err) {
return callback(err);
}
if (needed) {
console.log('Minification complete.');
}
return callback(null);
});
},
minifiedStatic: function(callback) {
_.each(self._minified, function(byType, scene) {
_.each(byType, function(content, type) {
if (!typeMap[type]) {
return;
}
var dir = self.options.rootDir + '/public/apos-minified';
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir);
}
var filename = dir + '/' + scene + '-' + self._generation + '.' + typeMap[type];
// Avoid race conditions - don't try to write the
// same file again if apostrophe:generation already
// created it for us
if (fs.existsSync(filename)) {
return;
}
if ((type === 'stylesheets') && self.options.bless) {
var bless = require('bless');
var output = path.dirname(filename);
new (bless.Parser)({
output: output,
options: {}
}).parse(content.toString(), function (err, files) {
if (files.length === 1) {
// No splitting needed, small enough for <= IE9 already
if (!fs.existsSync(filename)) {
fs.writeFileSync(filename, content);
}
return;
}
var master = '';
var n = 1;
_.each(files, function(file) {
var filePath = addN(filename);
var basename = path.basename(filename);
var webPath = addN(basename);
fs.writeFileSync(filePath, file.content);
master += '@import url("' + webPath + '");\n';
n++;
});
function addN(filename) {
return filename.replace(/\.css$/, '-' + n + '.css');
}
fs.writeFileSync(filename, master);
});
} else {
fs.writeFileSync(filename, content);
}
});
});
return setImmediate(callback);
}
}, callback);
};
// Make a symbolic link so that "from" points
// to the same content as "to". Remove any
// existing link at "from" first. On Windows, this
// method actually syncs the files to a new folder,
// which is necessary to avoid the need for
// administrative rights.
self.relinkAssetFolder = function(from, to) {
var os = require('os');
if (os.platform().match(/^win/i)) {
windows();
} else {
unix();
}
function windows() {
var wrench = require('wrench');
// First remove any legacy symbolic link
// to avoid problems with wrench
try {
// If symbolic link exists, remove it
// so we can replace it with
// a valid one
fs.unlinkSync(from);
} catch (e) {
// Old symbolic link does not exist or
// there is already a folder, that's fine
}
// To fake a symbolic link, you need
// to copy TO where the symbolic link
// would be, and FROM the real stuff.
// So to and from are reversed here. -Tom
wrench.copyDirSyncRecursive(to, from, {
forceDelete: true
});
}
function unix() {
// Always recreate the links so we're not befuddled by links
// deployed from a dev environment
try {
// If symbolic link exists, remove it
// so we can replace it with
// a valid one
fs.unlinkSync(from);
} catch (e) {
// Old symbolic link does not exist, that's fine
}
fs.symlinkSync(to, from, 'dir');
}
};
// Iterate over all asset scenes and possible upgrades between them. Right
// now it's just anon, user, and anon.user, but I'm coding for the future
self.forAllAssetScenesAndUpgrades = function(each, callback) {
if (!self.options.scenes) {
self.options.scenes = [ 'anon', 'user' ];
}
var scenesAndUpgrades = [];
var scenes = self.options.scenes;
_.each(scenes, function(scene) {
scenesAndUpgrades.push(scene);
});
// Express all the possible upgrades between scenes in dot notation
var i, j;
for (i = 0; (i < (scenes.length - 1)); i++) {
for (j = i + 1; (j < scenes.length); j++) {
scenesAndUpgrades.push(scenes[i] + '.' + scenes[j]);
}
}
return async.eachSeries(scenesAndUpgrades, each, callback);
};
// Minify assets required for a particular scene and populate
// self._minified appropriately. Supports dot notation in "scene"
// for scene upgrades. Implements md5-based caching for the really
// expensive javascript minification step.
self.minifySceneAssetType = function(scene, type, minifier, callback) {
var assets;
// For stylesheets we should have a master LESS file at this point which
// imports all the rest, so treat that as our sole stylesheet
if ((type === 'stylesheets') && self._lessMasters && self._lessMasters[scene]) {
assets = [ self._lessMasters[scene] ];
} else {
assets = self.filterAssets(self._assets[type], scene, true);
}
var key;
var found = false;
if (!self._minified) {
self._minified = {};
}
if (!self._minified[scene]) {
self._minified[scene] = {};
}
var cache = self.getCache('minify');
return async.series({
checkCache: function (callback) {
if (type === 'stylesheets') {
// For now we must ignore the cache for stylesheets because
// LESS files may include other files which may have been
// modified, and we have not accounted for that
return callback(null);
}
return async.map(assets, function(asset, callback) {
if (!fs.existsSync(asset.file)) {
// It is not uncommon to push assets that a developer doesn't
// bother to create in, say, a snippet subclass
return callback(null, 'empty');
}
return self.md5File(asset.file, callback);
}, function(err, md5s) {
if (err) {
return callback(err);
}
// So the key's components are in a consistent order
md5s.sort();
key = type + ':' + scene + ':' + md5s.join(':');
// So the key is never too long for mongodb
key = self.md5(key);
return cache.get(key, function(err, item) {
if (err) {
return callback(err);
}
if (item !== undefined) {
self._minified[scene][type] = item;
found = true;
}
return callback(null);
});
});
},
compileIfNeeded: function(callback) {
if (found) {
return callback(null);
}
return async.mapSeries(assets, minifier, function(err, codes) {
if (err) {
return callback(err);
}
var code = codes.join("\n");
self._minified[scene][type] = code;
return cache.set(key, code, callback);
});
}
}, callback);
};
self.minifyStylesheet = function(stylesheet, callback) {
return self.compileStylesheet(stylesheet, function(err, code) {
if (err) {
return callback(err);
}
return callback(null, cleanCss.process(code));
});
};
// Minify a single JavaScript file (via the script.file property)
self.minifyScript = function(script, callback) {
// For now we don't actually need async for scripts, but now
// we have the option of going there
var exists = fs.existsSync(script.file);
if (!exists) {
console.log("Warning: " + script.file + " does not exist");
return callback(null);
}
if (script.preshrunk) {
return callback(null, fs.readFileSync(script.file, 'utf8'));
}
var code = uglifyJs.minify(script.file).code;
return callback(null, code);
};
self.compileStylesheet = function(stylesheet, callback) {
var result;
var src = stylesheet.file;
// Make sure we look first for a LESS source file so we're not
// just using a (possibly stale) previously compiled version
var lessPath = src.replace(/\.css$/, '.less');
var exists = false;
if (fs.existsSync(lessPath)) {
src = lessPath;
exists = true;
} else if (fs.existsSync(src)) {
exists = true;
}
if (!exists) {
console.log('WARNING: stylesheet ' + stylesheet.file + ' does not exist');
return callback(null, '');
}
// We run ALL CSS through the LESS compiler, because
// it fixes relative paths for us so that a combined file
// will still have valid paths to background images etc.
return less.render(fs.readFileSync(src, 'utf8'),
{
filename: src,
rootpath: path.dirname(stylesheet.web) + '/',
// Without this relative import paths are in trouble
paths: [ path.dirname(src) ],
// syncImport doesn't seem to work anymore in 1.4, thus
// we were pushed to write endAssets, although it makes
// sense anyway
}, function(err, css) {
if (self.prefix) {
// Call a method provided by appy to be
// compatible with what the frontend
// middleware does
css = self.options.prefixCssUrls(css);
}
return callback(err, css);
});
};
// Part of the implementation of apos.endAssets, this method
// returns only the assets that are suitable for the specified
// scene (`user` or `anon`). Duplicates are suppressed automatically
// for anything rendered from a file (we can't do that for things
// rendered by a function).
//
// If minifiable is true you get back the assets that can be minified;
// if set false you get those that cannot; if it is not specified
// you get both.
//
// If "when" specifies two scenes separated by a dot:
//
// anon.user
//
// Then the returned assets are those necessary to upgrade from the
// first to the second. (Note that for stylesheets this is not safe
// because you will be missing LESS mixins set in the files the
// user already has. So in that case we just push all the styles
// for the larger scene rather than calling this method with
// a transitional scene name.)
self.filterAssets = function(assets, when, minifiable) {
// Support older layouts
if (!when) {
throw new Error('You must specify the "when" argument (usually either anon or user)');
}
// Handle upgrades
var matches = when.match(/^(.*?)\.(.*)$/);
if (matches) {
var from = matches[1];
var to = matches[2];
var alreadyHave = {};
var need = {};
_.each(self.filterAssets(assets, from), function(asset) {
alreadyHave[self.assetKey(asset)] = asset;
});
_.each(self.filterAssets(assets, to), function(asset) {
need[self.assetKey(asset)] = asset;
});
var result = [];
_.each(need, function(asset, assetKey) {
if (!alreadyHave[assetKey]) {
result.push(asset);
}
});
return result;
}
// Always stomp duplicates so that devs don't have to worry about whether
// someone else pushed the same asset.
var once = {};
var results = _.filter(assets, function(asset) {
if (minifiable !== undefined) {
if (minifiable === true) {
if (asset.minify === false) {
return false;
}
}
if (minifiable === false) {
if (asset.minify !== false) {
return false;
}
}
}
var relevant = (asset.when === 'always') || (when === 'all') || (asset.when === when);
if (!relevant) {
return false;
}
if (asset.call) {
// We can't stomp duplicates for templates rendered by functions
return true;
}
var key = asset.name + ':' + asset.fs + ':' + asset.web;
if (once[key]) {
return false;
}
once[key] = true;
return true;
});
return results;
};
// Given the name of an asset module, like blog or my-blog or snippets,
// this gives you access to an array of asset modules in order to resolve
// overrides beginning at that point. That is, if you look up "blog", you get
// an array like this:
//
// [
// { name: 'blog', web: '/apos-blog', dir: '.../node_modules/apostrophe-blog' },
// { name: 'snippets', web: '/apos-snippets', dir: '.../node_modules/apostrophe-snippets' }
// ]
//
// If you look up "my-blog" in a project that contains the blog module and
// overrides in lib/modules/apostrophe-blog, you get this:
//
// [
// { name: 'my-blog', web: '/apos-my-blog', dir: '.../lib/modules/apostrophe-blog' },
// { name: 'blog', web: '/apos-blog', dir: '.../node_modules/apostrophe-blog' },
// { name: 'snippets', web: '/apos-snippets', dir: '.../node_modules/apostrophe-snippets' }
// ]
self._assetChains = {};
// This mixin adds methods to the specified module object. Should be called only
// in base classes, like `apostrophe-snippets` itself, never a subclass like
// `apostrophe-blog`.
//
// IF YOU ARE WRITING A SUBCLASS
//
// when subclassing you do NOT need to invoke this mixin as the methods
// are already present on the base class object. Instead, just make sure you
// add your module name and directory to the `modules` option before invoking
// the constructor of your superclass:
//
// `options.modules = (options.modules || []).concat([ { dir: __dirname, name: 'blog' } ]);`
//
// Then you may call `self.pushAsset` as described below and your assets will be
// part of the result.
//
// HOW TO PUSH ASSETS
//
// If you are creating a new module from scratch that does not subclass another,
// invoke the mixin this way in your constructor:
//
// `self._apos.mixinModuleAssets(self, 'apostrophe-snippets', __dirname, options);`
//
// Then you may invoke:
//
// self.pushAsset('script', 'editor', { when: 'user' })
//
// This pushes `public/js/editor.js` to the browser, exactly like
// `apos.pushAsset` would, but also pushes any `public/js/editor.js` files
// provided a subclass, starting with the base class version.
//
// Stylesheets work the same way:
//
// `self.pushAsset('stylesheet', 'editor', { when: 'user' })`
//
// That pushes `editor.less`, for the base class and its subclasses as appropriate.
//
// DOM templates can also be pushed:
//
// `self.pushAsset('template', 'invitation', { when: 'always' })`
//
// If this is called by the `blog` module and it subclasses the `snippets` module,
// then only the blog module's `views/invitation.html` will be added to the DOM.
//
// See `apos.pushAsset` for details on pushing assets in general.
//
// HOW TO SERVE ASSETS
//
// It just works! Your assets folder will be symlinked into public/modules.
// (TODO: for the poor fellows trapped in Windows, copy the folders recursively.)
//
// URLS FOR ASSETS
//
// Most assets are pushed, which makes it Apostrophe's job to deliver them with
// valid URLs that load them. But if you want to access an asset directly, for
// instance an image in a `public/images` subdirectory of your module, you can
// do so.
//
// If the `name` argument to `self._apos.mixinModuleAssets` is `cats`, then
// the `public` directory of the module will be visible to the browser as
// `/modules/cats`.
//
// If you are creating a subclass called `tabby` and adjusting the `modules`
// option correctly as discussed above, then your `public` directory will
// be accessible as `/modules/tabby`.
//
// URLS FOR API ROUTES
//
// It is easiest to add your own API routes if you do not use the same
// "folder" used to serve static assets. We suggest using `/apos-tabby-api`
// as a prefix so that static asset URLs like `/apos-tabby/...` do not
// block your API routes.
//
// OTHER METHODS PROVIDED BY THE MIXIN
//
// `self.render(name, data)` renders a template found in this module or the most
// immediate ancestor that offers it. It is used to implement
// `self.pushAsset('template', 'templatename')` and may also be used directly.
//
// `self.renderer(name)` returns a function which, given the named template and
// a data object, will render that template with the given data according to the
// same rules as `self.render`. It is used to implement deferred rendering.
//
// `self.renderPage(req, name, data)` renders a complete web page, with the content
// rendered by the specified template, decorated by the outerLayout. BC BREAK:
// new parameters in 0.5. This method is now fully capable of pushing assets,
// pushing javascript, etc. just like a page served by the pages module.
self.mixinModuleAssets = function(module, name, dirname, options) {
module._modules = (options.modules || []).concat([ { name: name, dir: dirname } ]);
var i;
// Make all the asset modules in the chain findable via
// apos._assetChains, supplying the remainder of the chain for
// each one. Allows nunjucksLoader.js to implement cross-module includes
// with support for overrides
for (i = 0; (i < module._modules.length); i++) {
self._assetChains[self.cssName(module._modules[i].name)] = module._modules.slice(i);
}
module._rendererGlobals = options.rendererGlobals || {};
// Compute the web directory name for use in asset paths
_.each(module._modules, function(module) {
module.web = '/modules/' + self.cssName(module.name);
// Also record the filesystem path of each web path so that we can
// create symlinks making them equivalent
self._assetPaths[module.web] = module.dir;
});
// The same list in reverse order, for use in pushing assets (all versions of the
// asset file are pushed to the browser, starting with the snippets class, because
// CSS and JS are cumulative and CSS is very order dependent)
//
// Use slice(0) to make sure we get a copy and don't alter the original
module._reverseModules = module._modules.slice(0).reverse();
// Render a partial, looking for overrides in our preferred places.
module.render = function(name, data, req) {
return module.renderer(name, req)(data);
};
// Render a template in a string (not from a file), looking for includes, etc. in our
// preferred places
module.renderString = function(s, data) {
return module.rendererString(s)(data);
};
// Return a function that will render a particular partial looking for overrides in our
// preferred places. Also merge in any properties of self._rendererGlobals, which can
// be set via the rendererGlobals option when the module is configured
module.renderer = function(name, req) {
return function(data, reqAnonymous) {
req = reqAnonymous || req;
if (!data) {
data = {};
}
_.defaults(data, module._rendererGlobals);
return self.partial(req, name, data, _.map(module._modules, function(module) { return module.dir + '/views'; }));
};
};
// Return a function that will render a particular template in a string,
// looking includes etc. preferred places. Also merge in any properties of
// self._rendererGlobals, which can be set via the rendererGlobals option
// when the module is configured
module.rendererString = function(s) {
return function(data) {
if (!data) {
data = {};
}
_.defaults(data, module._rendererGlobals);
return self.partialString(s, data, _.map(module._modules, function(module) { return module.dir + '/views'; }));
};
};
module.pushAsset = function(type, name, optionsArg) {
var options = {};
if (optionsArg) {
extend(true, options, optionsArg);
}
if (type === 'template') {
// Render templates in our own nunjucks context
self.pushAsset('template', module.renderer(name), options);
} else {
// We're interested in ALL versions of main.js or main.less,
// starting with the base one (snippets module version, if this module is descended from snippets). CSS and JS are additive.
var exists = false;
_.each(module._reverseModules, function(module) {
var path;
options.fs = module.dir;
options.web = module.web;
if (self.pushAsset(type, name, options)) {
exists = true;
}
});
if (!exists) {
console.error('WARNING: no versions of the ' + type + ' ' + name + ' exist, but you are pushing that asset in the ' + module.name + ' module.');
}
}
};
// Generate a complete HTML page for transmission to the browser.
//
// Renders the specified template in the context of the current module,
// then decorates it with the outer layout. Pushes javascript calls and
// javascript data to the browser and always passes the following to the
// template:
//
// user (req.user)
// query (req.query)
// permissions (req.user.permissions)
//
// Under the following conditions, the outer layout is skipped and
// the template's result is returned directly:
//
// req.xhr is true (always set on AJAX requests by jQuery)
// req.query.xhr is set to simulate an AJAX request
// req.decorate is false
//
// This is helpful when the same logic is used to power regular
// pages, RSS views and partial refreshes like infinite scroll "pages".
//
// The ready event is always triggered on the body, whether
// performing an AJAX update or a fully decorated page rendering.
//
// If template is a function it is passed a data object and also
// the request object (ignored in most cases). Otherwise it is rendered
// as a nunjucks template relative to this module's views folder.
//
// If `when` is set to 'user' or 'anon' it overrides the normal determination
// of whether the page requires full CSS and JS for a logged-in user via
// req.user.
module.renderPage = function(req, template, data, when) {
var workflow = self.options.workflow && {
mode: req.session.workflowMode || 'public'
};
req.pushData({
permissions: (req.user && req.user.permissions) || {},
workflow: workflow
});
when = when || (req.user ? 'user' : 'anon');
if (req.scene === 'user') {
// Upgrade the scene for page loaders
// that request it. Much less awkward
// than working with requireScene
when = 'user';
}
var calls = self.getGlobalCallsWhen('always');
if (when === 'user') {
calls = calls + self.getGlobalCallsWhen('user');
}
calls = calls + self.getCalls(req);
// Always the last call; signifies we're done initializing the
// page as far as the core is concerned; a lovely time for other
// modules and project-level javascript to do their own
// enhancements. The content area refresh mechanism also
// triggers this event. Use afterYield to give other things
// a chance to finish initializing
calls += '\napos.afterYield(function() { apos.emit("ready"); });\n';
// JavaScript may want to know who the user is. Prune away
// big stuff like their profile areas
if (req.user) {
// This should be gone already but let's be doubly sure!
delete req.user.password;
req.traceIn('prune user');
req.pushData({ user: self.prunePage(req.user) });
req.traceOut();
}
var args = {
// Make sure we pass the slug of the page, not the
// complete URL. Frontend devs are expecting to be able
// to use this slug to attach URLs to a page
user: req.user,
permissions: (req.user && req.user.permissions) || {},
when: when,
calls: calls,
data: self.getGlobalData() + self.getData(req),
refreshing: !!req.query.apos_refresh,
// Make the query available to templates for easy access to
// filter settings etc.
query: req.query,
safeMode: (req.query.safe_mode !== undefined)
};
req.extras = req.extras || {};
_.extend(req.extras, data);
if (workflow && (workflow.mode === 'public')) {
self.workflowPreventEditInPublicMode(req.extras);
}
_.extend(args, req.extras);
var content;
try {
if (typeof(template) === 'string') {
content = module.render(template, args, req);
} else {
content = template(args, req);
}
} catch (e) {
// We're medium-screwed: the page template
// threw an exception. Log where it
// occurred for easier debugging
return error(e, 'template');
}
if (req.xhr || req.query.xhr || (req.decorate === false)) {
return content;
} else {
args.content = content;
try {
return self.decoratePageContent(args, req);
} catch (e) {
// We're extra-screwed: the outer layout
// template threw an exception.
// Log where it occurred for
// easier debugging
return error(e, 'outer layout');
}
}
function error(e, type) {
var now = Date.now();
now = moment(now).format("YYYY-MM-DDTHH:mm:ssZZ");
console.error(':: ' + now + ': ' + type + ' error at ' + req.url);
console.error('Current user: ' + (req.user ? req.user.username : 'none'));
console.error(e);
req.statusCode = 500;
return module.render('templateError', {}, req);
}
};
module.serveAssets = function() {
console.error('DEPRECATED: you do not have to call serveAssets anymore.');
};
};
// Fetch an asset chain by name. By default, if
// "blog" is requested, "my-blog" will automatically be
// tried first, to allow project-level overrides to
// be seen. You can pass false as the second argument
// to insist on the original version of the content.
self.getAssetChain = function(name, withOverrides) {
if (withOverrides !== undefined) {
var result = self.getAssetModule('my-' + name, false);
if (result) {
return result;
}
}
if (self._assetChains[name]) {
return self._assetChains[name];
}
};
var i;
for (i in stylesheets) {
self.pushAsset('stylesheet', stylesheets[i]);
}
for (i in scripts) {
self.pushAsset('script', scripts[i]);
}
for (i in templates) {
self.pushAsset('template', templates[i]);
}
},
/**
* Initialization requiring resources not available until init()
* @param {Object} self The apos object
*/
init: function(self) {
self._minified = {};
// Deprecated, shouldn't happen
self.app.get('/apos/stylesheets.css', function(req, res) {
console.error('ERROR: /apos/stylesheets.css should never be accessed anymore if you are using aposStylesheets properly in base.html');
res.statusCode = 404;
return res.send('Deprecated');
});
// Deprecated, shouldn't happen
self.app.get('/apos/scripts.js', function(req, res) {
console.error('ERROR: /apos/scripts.js should never be accessed anymore if you are using aposScripts properly in base.html');
res.statusCode = 404;
return res.send('Deprecated');
});
// This route allows us to upgrade the CSS, JS and DOM templates in the
// browser to include those required for a more complex "scene." i.e., we
// can go from "anon" to "user", in order to let an anonymous person
// participate in submitting moderated content.
//
// IMPLEMENTATION NOTE
//
// Loading JS and CSS and HTML and firing a callback after all the JS
// and HTML is really ready is kinda hard! Apostrophe mostly
// avoids it, because the big kids are still fighting about requirejs
// versus browserify, but when we upgrade the scene to let an anon user
// play with schema-driven forms, we need to load a bunch of JS and CSS
// and HTML in the right order! What will we do?
//
// We'll let the server send us a brick of CSS, a brick of JS, and a
// brick of HTML, and we'll smack the CSS and HTML into the DOM,
// wait for DOMready, and run the JS with eval.
//
// This way the server does most of the work, calculating which CSS, JS
// and HTML template files aren't yet in browserland, and the order of
// loading within JS-land is reallllly clear.
//
// Plenty of opportunity to add caching in production, but the idea
// is that a user only needs to switch scenes once during their session.
self.app.post('/apos/upgrade-scene', function(req, res) {
var from = self.sanitizeString(req.body.from);
var to = self.sanitizeString(req.body.to);
var result = {
css: '',
js: '',
html: ''
};
// For stylesheets it is not safe to compute a difference, compiling
// only the "user" stylesheets when upgrading from "anon" to
// "user", because we would be missing LESS mixins defined in the
// "anon" scene. Unfortunately we no alternative
// but to push the entire set of styles for the new scene.
// Hypothetically we could diff the actual CSS output in some way
// to compute the set of new styles that have to be sent.
// Practically speaking that would be a nightmare. -Tom
var cacheKey = to;
var stylesheets;
if (self._minified[cacheKey] && self._minified[cacheKey]['stylesheets']) {
// Use cached CSS if it was calculated at startup
result.css = self._minified[cacheKey]['stylesheets'];
} else if (self._lessMasters && self._lessMasters[cacheKey]) {
// Use a master LESS file if it was created at startup
stylesheets = [ self._lessMasters[cacheKey] ];
} else {
stylesheets = self.filterAssets(self._assets['stylesheets'], cacheKey);
}
// For javascript and DOM templates we can compute
// the difference safely
cacheKey = from + '.' + to;
var scripts;
// Use cached upgrade if it was calculated at startup
if (self._minified[cacheKey] && self._minified[cacheKey]['scripts']) {
result.js = self._minified[cacheKey]['scripts'];
} else {
scripts = self.filterAssets(self._assets['scripts'], cacheKey);
}
var templates;
// Use cached upgrade if it wa