apostrophe
Version:
Apostrophe is a user-friendly content management system. You'll need more than this core module. See apostrophenow.org to get started.
718 lines (683 loc) • 25.7 kB
JavaScript
var path = require('path');
var extend = require('extend');
var _ = require('lodash');
var sanitize = require('validator').sanitize;
var async = require('async');
var joinr = require('joinr');
/**
* editor
* @augments Augments the apos object with methods, routes and
* properties supporting the editing of content areas
*/
module.exports = {
construct: function(self) {
// This is our standard set of controls. If you add a new widget you'll be
// adding that to self.itemTypes (with widget: true) and to this list of
// default controls - or not, if you think your widget shouldn't be available
// unless explicitly specified in a aposArea call. If your project should *not*
// offer a particular control, ever, you can remove it from this list
// programmatically
// Removed the code widget for now in favor of giving 'pre' in the format dropdown a try
self.defaultControls = [ 'style', 'bold', 'italic', 'createLink', 'unlink', 'insertUnorderedList', 'insertTable', 'slideshow', 'buttons', 'video', 'files', 'embed', 'pullquote', 'html' ];
// These are the controls that map directly to standard document.executeCommand
// rich text editor actions. You can modify these to introduce other simple verbs that
// are supported across all browsers by document.execCommand, or to add or remove
// tags from the choices array of apos.controlTypes.style, but if you introduce
// commands or tags that the browser does not actually support it will not
// do what you want.
//
// This is not the place to define widgets. See apos.itemTypes for that.
self.controlTypes = {
style: {
type: 'menu',
label: 'Style',
choices: [
{ value: 'div', label: 'Normal' },
{ value: 'h3', label: 'Heading 3' },
{ value: 'h4', label: 'Heading 4' },
{ value: 'h5', label: 'Heading 5' },
{ value: 'h6', label: 'Heading 6' },
{ value: 'pre', label: 'Preformatted' },
]
},
bold: {
type: 'button',
label: 'Bold',
icon: 'bold'
},
italic: {
type: 'button',
label: 'Italic',
icon: 'italic'
},
createLink: {
type: 'button',
label: 'Link',
icon: 'link'
},
unlink: {
type: 'button',
label: 'Unlink',
icon: 'unlink'
},
insertUnorderedList: {
type: 'button',
label: 'List',
icon: 'ul'
},
insertTable: {
type: 'button',
label: 'Table',
icon: 'table'
}
};
// Sanitize an array of content items in-place, invoking the sanitize
// method of the itemType object for each.
self.sanitizeItems = function(req, items, callback)
{
var results = [];
async.eachSeries(items, function(item, callback) {
var itemType = self.itemTypes[item.type];
if (!itemType) {
// We don't know what this is, just preserve it
results.push(item);
return setImmediate(callback);
}
// Widget has no sanitizer (tsk)
if (!itemType.sanitize) {
results.push(item);
return setImmediate(callback);
}
// Simple, synchronous sanitizer that
// modifies in place
if (itemType.sanitize.length === 1) {
itemType.sanitize(item);
results.push(item);
return setImmediate(callback);
}
// Modern async sanitizer
return itemType.sanitize(req, item, function(err, _item) {
if (err) {
return callback(err);
}
// Sanitizer isn't responsible for the type field or
// the lockup field
_item.type = item.type;
if (item.lockup && _.has(self.lockups, item.lockup)) {
_item.lockup = item.lockup;
}
results.push(_item);
return callback(null);
});
}, function(err) {
if (err) {
return callback(err);
}
return callback(null, results);
});
};
// Sanitize a slideshow. Often reused by widgets that subclass
// the slideshow.
self.sanitizeSlideshow = function(item) {
if (!Array.isArray(item.ids)) {
item.ids = [];
}
item.showTitles = self.sanitizeBoolean(item.showTitles);
item.showDescriptions = self.sanitizeBoolean(item.showDescriptions);
item.showCredits = self.sanitizeBoolean(item.showCredits);
if (typeof(item.extras) !== 'object') {
item.extras = {};
}
var ids = [];
var extras = {};
_.each(item.ids, function(id) {
id = self.sanitizeString(id);
if (!id) {
return;
}
var extra = item.extras[id];
if (typeof(extra) !== 'object') {
extra = {};
}
var newExtra = {
hyperlink: self.sanitizeUrl(extra.hyperlink, undefined),
hyperlinkTitle: self.sanitizeString(extra.hyperlinkTitle, undefined),
hyperlinkTarget: self.sanitizeBoolean(extra.hyperlinkTarget)
};
if (extra.crop) {
newExtra.crop = {
top: self.sanitizeInteger(extra.crop.top),
left: self.sanitizeInteger(extra.crop.left),
width: self.sanitizeInteger(extra.crop.width),
height: self.sanitizeInteger(extra.crop.height)
};
}
extras[id] = newExtra;
ids.push(id);
});
item.ids = ids;
item.extras = extras;
return item;
};
// Fetch information about the files associated with a slideshow widget
// and make them accessible via the `._items` property of the widget.
// Also used by widgets that subclass slideshows.
self.loadSlideshow = function(req, item, callback) {
if (!item.ids) {
// Tolerate lazy representations of empty slideshows
return setImmediate(callback);
}
// We're loading a page and we want to defer all of the slideshow joins
// until we know about all of them and can do a single efficient mongo query
if (req.deferredLoads) {
if (!req.deferredLoads.slideshows) {
req.deferredLoads.slideshows = [];
req.deferredLoaders.slideshows = self.joinSlideshows;
}
req.deferredLoads.slideshows.push(item);
return setImmediate(callback);
}
// Nope, we're doing it now
return self.joinSlideshows(req, [ item ], callback);
};
// Join an array of slideshow widgets to their files in one fell swoop,
// using joinr and some glue code. Used by self.loadSlideshow and also by
// the deferredLoads mechanism which greatly speeds up slideshow loading
// when there are many documents being loaded.
self.joinSlideshows = function(req, items, callback) {
// Each item needs an _id to be compatible with joinr, so just invent one cheaply
// for the duration of this query.
var n = 1;
_.each(items, function(item) {
item._id = item._id || (n++);
// Don't get confused if an object already has a stale _items property
delete item._items;
});
return joinr.byArray(items, 'ids', 'extras', '_items', function(ids, callback) {
return self.getFiles(req, { ids: ids }, function(err, results) {
if (err) {
return callback(err);
}
return callback(null, results.files);
});
}, function(err) {
if (err) {
return callback(err);
}
// joinr is fastidious and keeps the item and the relationship to the item
// in separate properties, but everything else expects the relationship's
// properties to be merged into the file object, so do that. However we still
// need to make a new object for each _item because joinr does its best to
// share objects, but we're merging in placement-specific properties.
_.each(items, function(item) {
var merged = [];
_.each(item._items, function(file) {
var _item = {};
extend(true, _item, file.item);
extend(true, _item, file.relationship);
merged.push(_item);
});
item._items = merged;
});
return callback(null);
});
};
self.itemTypes = {
richText: {
markup: true,
icon: 'icon-align-left',
sanitize: function(item) {
var sanitizeHtml = require('sanitize-html');
item.content = sanitizeHtml(self.sanitizeString(item.content).trim(), self.options.sanitizeHtml);
},
// Used by apos.getAreaPlaintext. Should not be present unless this type
// actually has an appropriate plaintext representation for the public
// to view. Most widgets won't. This is distinct from diff and search, see below.
getPlaintext: function(item, lines) {
return self.htmlToPlaintext(item.content);
},
addDiffLines: function(item, lines) {
// Turn tags into line breaks, which generally produces some indication
// of a change around that point
var text = self.htmlToPlaintext(item.content);
self.addDiffLinesForText(text, lines);
},
addSearchTexts: function(item, texts) {
// Turn tags into line breaks, which generally produces some indication
// of a change around that point
var text = self.htmlToPlaintext(item.content);
texts.push({ weight: 1, text: text});
},
empty: function(item) {
// This is a little bit expensive, but it is otherwise very difficult to spot
// things like a placeholder empty div or solitary br generated by the rich text editor
// that designers consider "empty"
var text = self.htmlToPlaintext(item.content);
return (!text.trim().length);
},
label: 'Text'
},
slideshow: {
widget: true,
// per F&M training with Ilyssa and Rachel,
// "slideshow" is super baffling and invisible when
// you are thinking about adding one image. Solve it
// the same way we did for apostrophenow.com. -Tom, Joel & Ilyssa
label: 'Image(s)',
icon: 'icon-image',
// icon: 'slideshow',
sanitize: self.sanitizeSlideshow,
renderWidget: function(data) {
return self.partial('slideshow', data);
},
addDiffLines: function(item, lines) {
// Diff runs without the benefit of loaders for speed, so
// don't refer to anything we won't know
lines.push('slideshow: images selected: ' + ((item.ids && item.ids.length) || 0));
},
addSearchTexts: function(item, texts) {
var items = item._items || [];
_.each(items, function(item) {
texts.push({ weight: 1, text: item.name, silent: true });
});
},
empty: function(item) {
return !((item._items || []).length);
},
css: 'slideshow',
jsonProperties: [ '_items' ],
load: function(req, item, callback) {
return self.loadSlideshow(req, item, callback);
}
},
buttons: {
widget: true,
label: 'Button(s)',
icon: 'icon-button',
sanitize: self.sanitizeSlideshow,
// icon: 'slideshow',
renderWidget: function(data) {
return self.partial('buttons', data);
},
empty: function(item) {
return !((item._items || []).length);
},
css: 'buttons',
jsonProperties: [ '_items' ],
load: function(req, item, callback) {
return self.loadSlideshow(req, item, callback);
}
},
marquee: {
widget: true,
label: 'Marquee',
icon: 'icon-slideshow',
sanitize: self.sanitizeSlideshow,
// icon: 'slideshow',
renderWidget: function(data) {
return self.partial('marquee', data);
},
empty: function(item) {
return !((item._items || []).length);
},
addDiffLines: function(item, lines) {
// Diff runs without the benefit of loaders for speed, so
// don't refer to anything we won't know
lines.push('marquee: images selected: ' + ((item.ids && item.ids.length) || 0));
},
css: 'marquee',
jsonProperties: [ '_items' ],
load: function(req, item, callback) {
return self.loadSlideshow(req, item, callback);
}
},
files: {
widget: true,
label: 'Files',
icon: 'icon-file',
sanitize: self.sanitizeSlideshow,
renderWidget: function(data) {
var val = self.partial('files', data);
return val;
},
addDiffLines: function(item, lines) {
// Diff runs without the benefit of loaders for speed, so
// don't refer to anything we won't know
lines.push('files selected: ' + ((item.ids && item.ids.length) || 0));
},
addSearchTexts: function(item, texts) {
var items = item._items || [];
_.each(items, function(item) {
texts.push({ weight: 1, text: item.name, silent: true });
});
},
empty: function(item) {
return !((item._items || []).length);
},
css: 'files',
jsonProperties: [ '_items' ],
load: function(req, item, callback) {
return self.loadSlideshow(req, item, callback);
}
},
video: {
widget: true,
label: 'Video',
icon: 'icon-video',
renderWidget: function(data) {
return self.partial('video', data);
},
addDiffLines: function(item, lines) {
lines.push('video: ' + item.url);
},
css: 'video',
oembedType: 'video',
jsonConfiguration: [ 'oembedType' ]
},
embed: {
widget: true,
label: 'Embed',
icon: 'icon-beaker',
renderWidget: function(data) {
return self.partial('embed', data);
},
addDiffLines: function(item, lines) {
lines.push('embed: ' + item.url);
},
css: 'embed',
oembedNotType: 'video',
jsonConfiguration: [ 'oembedNotType' ]
},
pullquote: {
widget: true,
label: 'Pullquote',
plaintext: true,
wrapper: 'span',
icon: 'icon-quote-left',
// Without this it's bothersome for editor.js to grab the text
// without accidentally grabbing the buttons. -Tom
wrapperClass: 'apos-pullquote-text',
content: true,
contentSelector: '.apos-pullquote-text',
css: 'pullquote',
addDiffLines: function(item, lines) {
lines.push('pullquote: ' + item.content || '');
},
addSearchTexts: function(item, texts) {
texts.push({ weight: 1, text: item.content || ''});
},
},
code: {
widget: true,
label: 'Code',
// icon: 'code',
plaintext: true,
wrapper: 'pre',
content: true,
contentSelector: '.apos-pullquote-text',
css: 'code',
addDiffLines: function(item, lines) {
self.addDiffLinesForText(item.content ? item.content : '', lines);
},
addSearchTexts: function(item, texts) {
texts.push({ weight: 1, text: item.content || ''});
},
},
html: {
widget: true,
label: 'HTML',
icon: 'icon-code',
css: 'html',
addDiffLines: function(item, lines) {
self.addDiffLinesForText(item.content ? item.content : '', lines);
},
renderWidget: function(data) {
return self.partial('html', data);
}
}
};
// Register a new widget type's manager object on the server side
self.addWidgetType = function(name, type) {
self.itemTypes[name] = type;
};
// Returns a JSON representation of the information about this type that is suitable for
// transmission to the browser
self.jsonItemType = function(type) {
return _.pick(type, [ 'widget', 'label', 'icon', 'markup', 'css', 'content', 'contentSelector' ].concat(type.jsonConfiguration || []));
};
},
init: function(self) {
// Make the widget's configuration known on the browser side,
// without sending too much data or circular structures
var data = {};
_.each(self.itemTypes, function(type, name) {
data[name] = self.jsonItemType(type);
});
self.pushGlobalData({
widgetOptions: data
});
// Render an editor for a virtual area with the content
// specified as an array of items by the req.body.content
// property, if any. For use when you are supplying your own storage
// (for instance, the blog module uses this to render
// an area editor for the content of a post).
self.app.post('/apos/edit-virtual-area', function(req, res) {
var content = req.body.content || [];
var options = req.body.options || {};
var controls = options.controls || self.defaultControls;
// For bc
if (typeof(controls) === 'string') {
controls = controls.split(' ');
}
return self.sanitizeItems(req, content, function(err, items) {
var area = {
type: 'area',
items: items
};
// A temporary id for the duration of the editing activity, useful
// in the DOM. Regular areas are permanently identified by their slugs,
// not their IDs. Virtual areas are identified as the implementation sees fit.
var wid = 'w-' + self.generateId();
// "Always on" editors don't need a separate edit view,
// just pass the virtual option to prevent things like the
// slug and save attributes from appearing
options.edit = true;
options.virtual = true;
options.controls = controls;
options.styles = options.styles || self.controlTypes.style.choices;
return self.render(req, res, 'area', { options: options, area: area, id: wid });
});
});
// Render an editor for a virtual area with the content
// specified as a JSON array of items by the req.body.content
// property, if any (there will be 0 or 1 elements, any further
// elements are ignored). For use when you are supplying your own storage
// (for instance, the blog module uses this to render
// a singleton thumbnail edit button for a post).
self.app.post('/apos/edit-virtual-singleton', function(req, res) {
var options = req.body.options || {};
var content = req.body.content || [];
return self.sanitizeItems(req, content, function(err, items) {
var area = {
type: 'area',
items: items
};
var type = req.body.type;
// A temporary id for the duration of the editing activity, useful
// in the DOM. Regular areas are permanently identified by their slugs,
// not their IDs. Virtual areas are identified as the implementation sees fit.
area.wid = 'w-' + self.generateId();
extend(options, _.omit(req.body, 'content', 'type'), true);
options.type = type;
options.area = area;
options.edit = true;
// Must do this before directly invoking an apos* that might render a partial
self.initI18nLocal(req);
return res.send(self._aposLocals.aposSingleton(options));
});
});
self.app.post('/apos/edit-area', function(req, res) {
var options = req.body.options;
var slug = req.body.slug;
var content = req.body.content;
var items;
var area;
return async.series({
sanitize: function(callback) {
return self.sanitizeItems(req, content, function(err, _items) {
if (err) {
return callback(err);
}
items = _items;
return callback(null);
});
},
put: function(callback) {
area = {
slug: slug,
items: items,
type: 'area'
};
return self.putArea(req, slug, area, callback);
},
load: function(callback) {
return self.callLoadersForArea(req, area, callback);
}
}, function(err) {
if (err) {
console.error('error occurred in edit-area');
console.error(err);
return self.notfound(req, res);
}
// Must do this before directly invoking an apos* that might render a partial
self.initI18nLocal(req);
return res.send(self._aposLocals.aposAreaContent(area.items, options));
});
});
self.app.post('/apos/edit-singleton', function(req, res) {
var slug = req.body.slug;
var content = req.body.content;
var options = req.body.options;
// "OMG, what if they cheat and use a type not allowed for this singleton?"
// When they refresh the page they will discover they can't see their hack.
// aposSingleton only shows the first item of the specified type, regardless
// of what is kicking around in the database.
var type = content.type;
var itemType = self.itemTypes[type];
var area;
if (!itemType) {
return self.fail(req, res);
}
return async.series({
sanitize: function(callback) {
return self.sanitizeItems(req, [ content ], function(err, items) {
if (err) {
return callback(err);
}
if (!items.length) {
return callback(new Error('notfound'));
}
area = {
slug: req.body.slug,
items: items,
type: 'area'
};
return callback(null);
});
},
put: function(callback) {
return self.putArea(req, slug, area, callback);
},
load: function(callback) {
return self.callLoadersForArea(req, area, callback);
}
}, function(err) {
if (err) {
console.error('error occurred in edit-singleton');
console.error(err);
return self.notfound(req, res);
}
var areaOptions = {};
areaOptions[type] = options;
// Must do this before directly invoking an apos* that might render a partial
self.initI18nLocal(req);
return res.send(self._aposLocals.aposAreaContent(area.items, areaOptions));
});
});
// Used to render newly created, as yet unsaved widgets to be displayed in
// the main apos editor. We're not really changing anything in the database
// here. We're just allowing the browser to leverage the same normal view
// generator that the server uses for actual page rendering. Renders the
// body of the widget only since the widget div has already been updated
// or created in the browser. Options may be passed to the widget
// via the _options property, or via the query string. The query string
// wins.
self.app.post('/apos/render-widget', function(req, res) {
var item = req.body;
var options = {};
extend(options, req.body._options || {}, true);
extend(options, req.query || {}, true);
delete item._options;
var itemType = self.itemTypes[item.type];
if (!itemType) {
res.statusCode = 404;
return res.send('No such item type');
}
return async.series({
sanitize: function(callback) {
return self.sanitizeItems(req, [ item ], function(err, items) {
if (err) {
return callback(err);
}
if (!items.length) {
return callback(new Error('notfound'));
}
item = items[0];
return callback(null);
});
},
load: function(callback) {
if (!itemType.load) {
return callback(null);
}
return itemType.load(req, item, callback);
},
loadWidgets: function(callback) {
// Schema widget loaders invoke joins but do not
// invoke the loaders of areas and singletons, because
// that would be redundant; those are all invoked
// by callLoadersForPage when a page has been
// fully fetched.
//
// However, when we're previewing a widget or adding
// it to the page, we need to be able to correctly
// render a schema widget all by itself.
//
// So as a second loading pass, we invoke loaders
// for other widgets in the data properties of this
// widget exactly as a page would.
//
// We do it here, rather than in the schema widget
// loader, because here we know we're at the end of
// the process and avoiding redundant invocations.
// This also benefits any other widgets besides
// our official schema widget that may contain areas
// in their data. -Tom
return self.callLoadersForPage(
req,
{
_id: self.generateId(),
widget: item
}, callback
);
}
}, function(err) {
if (err) {
return self.notfound(req, res);
}
self.initI18nLocal(req);
return res.send(self._aposLocals.aposItemNormalView(item, options));
});
});
}
};