apostrophe
Version:
The Apostrophe Content Management System.
991 lines (958 loc) • 37.9 kB
JavaScript
const _ = require('lodash');
const { stripIndent } = require('common-tags');
const cheerio = require('cheerio');
// An area is a series of zero or more widgets, in which users can add
// and remove widgets and drag them to reorder them. This module implements
// areas, with the help of a query builder in the doc module. This module also
// provides browser-side support for invoking the players of widgets in an area
// and for editing areas.
module.exports = {
options: { alias: 'area' },
commands(self) {
return {
add: {
[`${self.__meta.name}:cut-widget`]: {
type: 'item',
label: 'apostrophe:commandMenuWidgetCut',
action: {
type: 'command-menu-area-cut-widget'
},
shortcut: 'Ctrl+X Meta+X'
},
[`${self.__meta.name}:copy-widget`]: {
type: 'item',
label: 'apostrophe:commandMenuWidgetCopy',
action: {
type: 'command-menu-area-copy-widget'
},
shortcut: 'Ctrl+C Meta+C'
},
[`${self.__meta.name}:paste-widget`]: {
type: 'item',
label: 'apostrophe:commandMenuWidgetPaste',
action: {
type: 'command-menu-area-paste-widget'
},
shortcut: 'Ctrl+V Meta+V'
},
[`${self.__meta.name}:duplicate-widget`]: {
type: 'item',
label: 'apostrophe:commandMenuWidgetDuplicate',
action: {
type: 'command-menu-area-duplicate-widget'
},
shortcut: 'Ctrl+Shift+D Meta+Shift+D'
},
[`${self.__meta.name}:remove-widget`]: {
type: 'item',
label: 'apostrophe:commandMenuWidgetRemove',
action: {
type: 'command-menu-area-remove-widget'
},
shortcut: 'Backspace'
}
},
modal: {
default: {
'@apostrophecms/command-menu:content': {
label: 'apostrophe:commandMenuContent',
commands: [
`${self.__meta.name}:cut-widget`,
`${self.__meta.name}:copy-widget`,
`${self.__meta.name}:paste-widget`,
`${self.__meta.name}:duplicate-widget`,
`${self.__meta.name}:remove-widget`
]
}
}
}
};
},
init(self) {
// These properties have special meaning in Apostrophe docs and are not
// acceptable for use as top-level area names
self.forbiddenAreas = [
'_id',
'title',
'slug',
'titleSort',
'docPermissions',
'type',
'path',
'rank',
'level'
];
self.richTextWidgetTypes = [];
self.widgetManagers = {};
self.enableBrowserData();
self.addDeduplicateWidgetIdsMigration();
self.createWidgetOperations = [];
},
apiRoutes(self) {
return {
post: {
async renderWidget(req) {
req.scene = 'apos';
const _docId = self.apos.launder.id(req.body._docId);
const livePreview = self.apos.launder.boolean(req.body.livePreview);
const {
manager, widget, field, type, options
} = await self.validateWidgetRequest(req, livePreview);
// The sanitization failed, but we are in live preview mode,
// so we need to return a special error code.
if (!widget && livePreview) {
return 'aposLivePreviewSchemaNotYetValid';
}
widget._edit = true;
widget._docId = _docId;
// So that carrying out relationship loading again can yield results
// (the idsStorage must be populated as if we were saving)
self.apos.schema.prepareForStorage(req, widget);
await load();
return render();
async function load() {
// Hint to call nested widget loaders as if it were a doc
widget._virtual = true;
return manager.loadIfSuitable(req, [ widget ]);
}
async function render() {
if (req.aposExternalFront) {
// Simulate an area and annotate it so that the
// widget's sub-areas wind up with the right metadata
const area = {
metaType: 'area',
items: [ widget ],
_docId
};
self.apos.template
.annotateAreaForExternalFront(field, area, { scene: req.scene });
// Annotate sub-areas. It's like annotating a doc, but not quite,
// so this logic is reproduced partially
self.apos.doc.walk(area, (o, k, v) => {
if (v && v.metaType === 'area') {
const manager = self.apos.util.getManagerOf(o);
if (!manager) {
self.apos.util.warnDevOnce(
'noManagerForDocInExternalFront',
`No manager for: ${o.metaType} ${o.type || ''}`
);
return;
}
const field = manager.schema.find(f => f.name === k);
v._docId = _docId;
self.apos.template
.annotateAreaForExternalFront(field, v, { scene: req.scene });
}
});
const result = {
...req.data,
options,
widget
};
return result;
}
return self.renderWidget(req, type, widget, options);
}
},
async validateWidget(req) {
const { widget } = await self.validateWidgetRequest(req);
return {
widget
};
}
}
};
},
handlers(self) {
return {
'apostrophe:modulesRegistered': {
getRichTextWidgetTypes() {
_.each(self.widgetManagers, function (manager, name) {
if (manager.getRichText) {
self.richTextWidgetTypes.push(name);
}
});
}
}
};
},
methods(self) {
return {
// Set the manager object for the given widget type name. The manager is
// expected to provide `sanitize`, `output` and `load` methods. Normally
// this method is called for you when you extend the
// `@apostrophecms/widget-type` module, which is recommended.
setWidgetManager(name, manager) {
self.widgetManagers[name] = manager;
},
// Given the options passed to the area field, return the options passed
// to each widget type, indexed by widget name. This provides a consistent
// interface regardless of whether `options.widgets` or `options.groups`
// was used.
getWidgets(options) {
// Keep in sync with client-side implementation
let widgets = options.widgets || {};
if (options.groups) {
for (const group of Object.keys(options.groups)) {
widgets = {
...widgets,
...options.groups[group].widgets
};
}
}
return widgets;
},
// Get the manager object for the given widget type name.
getWidgetManager(name) {
return self.widgetManagers[name];
},
// Validate a widget request. The `req.body` object is expected to contain
// - `widget` - the widget object to be validated
// - `areaFieldId` - the field id of the area field
// - `type` - the type of the widget
// Returns an object with the following properties:
// - `manager` - the widget manager object
// - `widget` - the sanitized widget object
// - `field` - the area field object
// - `type` - the type of the widget
// - `options` - the options for the widget type
// If `forLivePreview` is true and the widget is not valid, the method
// returns the same response but with `widget` set to null. Otherwise,
// failing to validate the widget results in an `invalid` error
// being thrown.
async validateWidgetRequest(req, forLivePreview = false) {
let widget = typeof req.body.widget === 'object' ? req.body.widget : {};
const areaFieldId = self.apos.launder.string(req.body.areaFieldId);
const type = self.apos.launder.string(req.body.type);
const field = self.apos.schema.getFieldById(areaFieldId);
if (!field) {
throw self.apos.error('invalid', 'Missing area field ID');
}
const widgets = self.getWidgets(field.options);
const options = widgets[type] || {};
const manager = self.getWidgetManager(type);
if (!manager) {
self.warnMissingWidgetType(type);
throw self.apos.error('invalid', 'Missing widget type');
}
try {
widget = await manager.sanitize(req, widget, options);
} catch (e) {
if (forLivePreview) {
return {
manager,
widget: null,
field,
type,
options
};
}
throw e;
}
return {
manager,
widget,
field,
type,
options
};
},
// Print warning message about a missing widget type — only once per run
// per type.
warnMissingWidgetType(name) {
if (!self.missingWidgetTypes) {
self.missingWidgetTypes = {};
}
if (!self.missingWidgetTypes[name]) {
self.apos.util.error(`WARNING: widget type ${name} exists in your database but is not configured.\n` +
`You probably do not have a ${name}-widget module in your project.`
);
self.missingWidgetTypes[name] = true;
}
},
prepForRender(area, context, fieldName) {
const manager = self.apos.util.getManagerOf(context);
const field = manager.schema.find(field => field.name === fieldName);
if (!field) {
throw new Error(`The requested ${context.metaType} has no field named ${fieldName}. In Apostrophe 3.x, areas must be part of the schema for each page or piece type.`);
}
area._fieldId = field._id;
area._docId = context._docId || ((context.metaType === 'doc') ? context._id : null);
area._edit = context._edit;
return area;
},
// Render the given `area` object via `area.html`, with the given
// `context` which may be omitted. Called for you by the `{% area %}
// custom tag.
//
// If `inline` is true then the rendering of each widget is attached
// to the widget as a `_rendered` property, bypassing normal full-area
// HTML responses, and the return value of this method is `null`.
//
// If an external front key is configured, ApostropheCMS will attempt
// to render the widget via Astro before attempting to render it
// natively.
async renderArea(req, area, _with, { inline = false } = {}) {
if (!area._id) {
throw new Error('All areas must have an _id property in A3.x. Area details:\n\n' + JSON.stringify(area));
}
const choices = [];
const field = self.apos.schema.getFieldById(area._fieldId);
const options = field.options;
if (!options) {
throw new Error(stripIndent`
The area field ${field.name} has no options property.
You probably forgot to nest the widgets property
in an options property.
`);
}
const widgets = self.getWidgets(options);
options.widgets = widgets;
_.each(widgets, function (options, name) {
const manager = self.widgetManagers[name];
if (manager) {
choices.push({
name,
icon: manager.options.icon,
label: options.addLabel || manager.label || `No label for ${name}`
});
}
});
// Guarantee that `items` at least exists
area.items = area.items || [];
if (area._docId) {
for (const item of area.items) {
item._docId = area._docId;
}
}
const canEdit = area._edit && (options.edit !== false) && req.query.aposEdit;
if (canEdit) {
// Ease of access to image URLs. When not editing we
// just use the helpers
self.apos.attachment.all(area, { annotate: true });
}
let externalError = null;
if (!self.apos.externalFrontKey) {
return renderNatively();
}
try {
return await renderViaExternalFront();
} catch (e) {
externalError = e;
}
try {
return await renderNatively();
} catch (e) {
throw new Error('Could not render area for API, neither via the external frontend nor natively.\n\n' +
'Check your Astro server logs as well.\n\n' +
niceError(externalError) + '\n\n' +
niceError(e)
);
}
async function renderViaExternalFront() {
if (!self.apos.baseUrl && self.apos.externalFrontKey) {
throw new Error('APOS_BASE_URL and APOS_EXTERNAL_FRONT_KEY must both be set in order to render\nvia the external frontend');
}
// Astro can render components or return JSON but not both, at least not without
// using its experimental container API which would potentially not have
// the same configuration as the main Astro project. So we let Astro be Astro,
// then we pull out the individual renderings with Cheerio. -Tom
const response = await fetch(`${self.apos.baseUrl}/api/apos-external-front/render-area`, {
method: 'POST',
headers: {
'apos-external-front-key': self.apos.externalFrontKey,
// Without this Astro enforces CSRF protection starting in version 4.9.0
'content-type': 'application/json'
},
body: JSON.stringify({
area
})
});
if (response.status >= 400) {
throw response;
}
const html = await response.text();
const $ = cheerio.load(`<div id="root">${html}</div>`);
if (inline) {
for (let i = 0; (i < area.items.length); i++) {
area.items[i]._rendered = $(`#root [data-widget-id="${area.items[i]._id}"]`).html() || '';
}
return null;
} else {
const $children = $('#root [data-widget-id]');
return $children.map(function() {
return $(this).html();
}).join('\n');
}
}
async function renderNatively() {
if (inline) {
for (const item of area.items) {
item._rendered = await self.renderWidget(
req,
item.type,
item,
widgets[item.type]
);
}
return null;
} else {
return self.render(req, 'area', {
// TODO filter area to exclude big relationship objects, but
// not so sloppy this time please
area,
field,
options,
choices,
_with,
canEdit,
scene: req.scene
});
}
}
},
// Replace documents' area objects with rendered HTML for each area.
// This is used by GET requests including the `render-areas` query
// parameter. `within` is an array of Apostrophe documents.
//
// If `inline` is true a rendering of each individual widget is
// added as an extra `_rendered` property of that widget, alongside
// its normal properties. Otherwise a rendering of the entire area
// is supplied as the `_rendered` property of that area and the
// `items` array is suppressed from the response.
async renderDocsAreas(req, within, { inline = false } = {}) {
within = Array.isArray(within) ? within : [];
let index = 0;
// Loop over the docs in the array passed in.
for (const doc of within) {
if (self.apos.externalFrontKey) {
self.apos.template.annotateDocForExternalFront(doc, { scene: req.scene });
}
const rendered = [];
const areasToRender = {};
// Walk the document's areas and stash the areas for rendering later.
self.walk(doc, async function (area, dotPath) {
// If this area is the child of another area, then we only want
// to render the parent area.
if (rendered.findIndex(path => dotPath.startsWith(`${path}.`)) > -1) {
return;
}
// We're only rendering areas on the document, not ancestor or
// child page or related documents.
const regex = /^_|\._/;
if (dotPath.match(regex)) {
return;
}
const parent = findParent(doc, dotPath);
// Only render areas whose parent has a metaType, which is required
// to find the area options.
if (parent && parent.metaType) {
rendered.push(dotPath);
areasToRender[dotPath] = area;
}
});
// Now go over the stashed areas and render their areas into HTML.
for (const path of Object.keys(areasToRender)) {
const parent = findParent(doc, path);
await render(areasToRender[path], path, parent);
}
within[index] = doc;
index++;
}
async function render(area, path, context) {
const preppedArea = self.prepForRender(area, context, path.split('.').at(-1));
const areaRendered = await self.apos.area.renderArea(
req,
preppedArea,
context,
{ inline }
);
if (inline) {
return;
}
_.set(context, [ path, '_rendered' ], areaRendered);
_.set(context, [ path, '_fieldId' ], undefined);
_.set(context, [ path, 'items' ], undefined);
}
function findParent(doc, dotPath) {
const pathSplit = dotPath.split('.');
const parentDotPath = pathSplit.slice(0, pathSplit.length - 1).join('.');
return _.get(doc, parentDotPath, doc);
}
},
// Sanitize an input array of items intended to become
// the `items` property of an area. Invokes the
// sanitize method for each widget manager. Widgets
// with no manager are discarded. Invoked for you by
// the routes that save areas and by the implementation
// of the `area` schema field type.
//
// The `options` parameter contains the area-level
// options to sanitize against. Thus h5 can be legal
// in one rich text widget and not in another.
//
// The `convertOptions` parameter allows to pass options
// to the convert method to alter them.
//
// If any errors occur sanitizing the individual widgets,
// an array of errors with `path` and `error` properties
// is thrown.
//
// Returns a new array of sanitized items.
async sanitizeItems(req, items, options, convertOptions = {}) {
options = options || {};
const result = [];
const errors = [];
const widgetsOptions = self.getWidgets(options);
for (let i = 0; i < items.length; i++) {
const item = items[i];
if ((item == null) || typeof item !== 'object' || typeof item.type !== 'string') {
continue;
}
const manager = self.getWidgetManager(item.type);
if (!manager) {
self.warnMissingWidgetType(item.type);
continue;
}
const widgetOptions = widgetsOptions[item.type];
if (!widgetOptions) {
// This widget is not specified for this area at all
continue;
}
let newItem;
try {
newItem = await manager.sanitize(req, item, widgetOptions, convertOptions);
newItem._id = self.apos.launder.id(item._id) || self.apos.util.generateId();
} catch (e) {
if (Array.isArray(e)) {
for (const error of e) {
errors.push({
path: i + '.' + error.path,
error: error.error
});
}
} else {
errors.push({
path: i,
error: e
});
}
}
result.push(newItem);
}
if (errors.length) {
throw errors;
} else {
return result;
}
},
// Renders markup for a widget of the given `type`. The actual
// content of the widget is passed in `data`. Returns html on
// success. Invoked by the `render-widget` route, which is used
// to update a widget on the page after it is saved, or for
// preview when editing.
async renderWidget(req, type, data, options) {
try {
const manager = self.getWidgetManager(type);
if (!manager) {
// No manager available - possibly a stale widget in the database
// of a type no longer in the project
self.warnMissingWidgetType(type);
return '';
}
data.type = type;
return manager.output(req, data, options);
} catch (e) {
console.error(e);
throw e;
}
},
// Update or create an area at the specified
// dot path in the document with the specified
// id, if we have permission to do so. The change is
// saved in the database. The
// `items` array is NOT sanitized here; you should call
// `sanitizeItems` first. Called for you by the
// `save-area` route.
async saveArea(req, docId, dotPath, items) {
const doc = await find();
return update(doc);
async function find() {
const doc = await self.apos.doc.find(req, { _id: docId }).permission('edit').toObject();
if (!doc) {
throw self.apos.error('notfound');
}
return doc;
}
async function update(doc) {
const components = dotPath.split(/\./);
if (_.includes(self.forbiddenAreas, components[0])) {
throw self.apos.error('forbidden');
}
// If it's not a top level property, it's
// always okay - unless it already exists
// and is not an area.
if (components.length > 1) {
const existing = _.get(doc, dotPath);
if (existing && existing.metaType !== 'area') {
throw self.apos.error('forbidden');
}
}
const existingArea = _.get(doc, dotPath);
const existingItems = existingArea && (existingArea.items || []);
const isEqual = _.isEqual(
self.apos.util.clonePermanent(items),
self.apos.util.clonePermanent(existingItems)
);
if (isEqual) {
// No real change — don't waste a version and clutter the database.
// Sometimes only the server-side sanitizers can tell accurately
// that nothing has changed. -Tom
return;
}
_.set(doc, dotPath, {
metaType: 'area',
items
});
return self.apos.doc.update(req, doc);
}
},
// Walk the areas in a doc. Your iterator function is invoked once
// for each area found, and receives the
// area object and the dot-notation path to that object.
// note that areas can be deeply nested in docs via
// array schemas.
//
// If the iterator explicitly returns `false`, the area
// is *removed* from the page object, otherwise no
// modifications are made. This happens in memory only;
// the database is not modified.
walk(doc, iterator) {
// o = object/doc, k = key, v = value
return self.apos.doc.walk(doc, function (o, k, v, dotPath) {
if (v && v.metaType === 'area') {
return iterator(v, dotPath);
}
});
},
// If the schema corresponding to the given doc's
// `type` property has an `options` property for the
// given field `name`, return that property. This is used
// to conveniently default to the `options` already configured
// for a particular area in the schema when working with
// `@apostrophecms/piece-type` in a page template.
getSchemaOptions(doc, name) {
const manager = self.apos.doc.getManager(doc.type);
if (!manager) {
// This happens if we try to find the schema options for an area in
// a widget or something else that isn't a top level doc type, or
// the projection did not include type.
//
// TODO: a better solution to the entire option-forwarding problem?
// -Tom
return {};
}
const schema = manager.schema;
const field = schema?.find(field => field.name === name);
if (!(field && field.options)) {
return {};
}
return field.options;
},
// Returns the rich text markup of all rich text widgets
// within the provided doc or area, concatenated as a single string.
//
// By default the rich text contents of the widgets are joined with
// a newline between. You may pass your own `options.delimiter` string if
// you wish a different delimiter or the empty string. You may also pass
// an HTML element name like `div` via `options.wrapper` to wrap each
// one in a `<div>...</div>` block. Of course, there may already be a div
// in the rich txt (but then again there may not).
//
// Also available as a helper via `apos.area.richText(area, options)` in
// templates.
//
// Content will be retrieved from any widget type that supplies a
// `getRichText` method.
richText(within, options) {
options = options || {};
function test(attachment) {
if (!attachment || typeof attachment !== 'object') {
return false;
}
if (!_.includes(self.richTextWidgetTypes, attachment.type)) {
return false;
}
return true;
}
const winners = [];
if (!within) {
return '';
}
self.apos.doc.walk(within, function (o, key, value, dotPath, ancestors) {
if (test(value)) {
winners.push(value);
}
});
if (options.wrapper) {
return _.map(winners, function (winner) {
return '<' + options.wrapper + '>' + winner.content + '</' + options.wrapper + '>';
}).join('');
}
const delimiter = options.delimiter !== undefined ? options.delimiter : '\n';
return _.map(winners, 'content').join(delimiter);
},
// Returns the plaintext contents of all rich text widgets
// within the provided doc or area, concatenated as a single string.
//
// By default the rich text contents of the various widgets are joined
// with a newline between. You may pass your own `options.delimiter`
// string if you wish a different delimiter or the empty string.
//
// Whitespace is trimmed off the leading and trailing edges of the
// string, and consecutive newlines are condensed to one, to better match
// reasonable expectations. re: text that began as HTML.
//
// Pass `options.limit` to limit the number of characters. This method
// will return fewer characters in order to avoid cutting off in mid-word.
//
// By default, three periods (`...`) follow a truncated string. If you
// prefer, set `options.ellipsis` to a different suffix, which may be the
// empty string if you wish.
//
// Also available as a helper via `apos.area.plaintext(area, options)` in
// templates.
//
// Content will be retrieved from any widget type that supplies a
// `getRichText` method.
plaintext(within, options) {
options = options || {};
const richText = self.richText(within, options);
let plaintext = self.apos.util.htmlToPlaintext(richText).trim();
plaintext = plaintext.replace(/\n+/g, '\n');
if (!options.limit) {
return plaintext;
}
let ellipsis = '...';
if (options.ellipsis !== undefined) {
ellipsis = options.ellipsis;
}
return self.apos.util.truncatePlaintext(plaintext, options.limit, ellipsis);
},
// Very handy for imports of all kinds: convert plaintext to an area with
// one `@apostrophecms/rich-text` widget if it is not blank, otherwise an
// empty area. null and undefined are tolerated and converted to empty
// areas.
fromPlaintext(plaintext) {
return self.fromRichText(self.apos.util.escapeHtml(plaintext, true));
},
// Convert HTML to an area with one '@apostrophecms/rich-text' widget,
// otherwise an empty area. null and undefined are tolerated and converted
// to empty areas.
fromRichText(html) {
const area = {
metaType: 'area',
items: [],
_id: self.apos.util.generateId()
};
if (html && html.length) {
html = html.toString();
area.items.push({
_id: self.apos.util.generateId(),
type: '@apostrophecms/rich-text',
content: html
});
}
return area;
},
// Load widgets which were deferred until as late as possible. Only
// comes into play if `req.deferWidgetLoading` was set to true for
// the request. Invoked after the last `pageBeforeSend` handler, and
// also at the end of the `@apostrophecms/global` middleware.
async loadDeferredWidgets(req) {
if (!req.deferWidgetLoading) {
return;
}
req.loadingDeferredWidgets = true;
for (const type of _.keys(req.deferredWidgets)) {
const manager = self.getWidgetManager(type);
if (!manager) {
self.warnMissingWidgetType(type);
continue;
}
await manager.loadIfSuitable(req, req.deferredWidgets[type]);
}
},
// Returns true if the named area in the given `doc` is empty.
//
// Alternate syntax: `{ area: doc.areaname, ... more options }`
//
// An area is empty if it has no widgets in it, or when
// all of the widgets in it return true when their
// `isEmpty()` methods are interrogated. For instance,
// if an area only contains a rich text widget and that
// widget. A widget with no `isEmpty()` method is never empty.
isEmpty(doc, name) {
let area;
if (arguments.length === 2) {
area = doc[name];
} else {
// "doc" is an options object
area = doc.area;
}
if (!area) {
return true;
}
return !_.some(area.items, function (item) {
const manager = self.getWidgetManager(item.type);
if (manager && manager.isEmpty) {
return !manager.isEmpty(item);
} else {
return true;
}
});
},
getBrowserData(req) {
const widgets = {};
const widgetEditors = {};
const widgetStylesEditors = {};
const widgetManagers = {};
const widgetIsContextual = {};
const widgetPreview = {};
const widgetHasPlaceholder = {};
const widgetHasInitialModal = {};
const contextualWidgetDefaultData = {};
_.each(self.widgetManagers, function (manager, name) {
const browserData = manager.getBrowserData(req);
widgets[name] = browserData?.components?.widget || 'AposWidget';
widgetEditors[name] = browserData?.components?.widgetEditor || 'AposWidgetEditor';
widgetStylesEditors[name] = browserData?.components?.widgetStylesEditor || 'AposWidgetEditor';
widgetManagers[name] = manager.__meta.name;
widgetIsContextual[name] = manager.options.contextual;
widgetPreview[name] = manager.options.preview;
widgetHasPlaceholder[name] = manager.options.placeholder;
widgetHasInitialModal[name] = !manager.options.placeholder &&
manager.options.initialModal !== false;
contextualWidgetDefaultData[name] = manager.options.defaultData || {};
});
return {
components: {
editor: 'AposAreaEditor',
widgets,
widgetEditors,
widgetStylesEditors
},
widgetIsContextual,
widgetHasPlaceholder,
widgetHasInitialModal,
widgetPreview,
contextualWidgetDefaultData,
widgetManagers,
action: self.action,
createWidgetOperations: self.createWidgetOperations
};
},
async addDeduplicateWidgetIdsMigration() {
self.apos.migration.add('deduplicate-widget-ids', () => {
// Make them globally unique because that is easiest to
// definitely get correct for this one-time migration, although
// there is no guarantee that widget ids are unique between
// separate documents going forward. The guarantee is that they
// will be unique within documents
const seen = new Set();
return self.apos.migration.eachWidget({}, async (doc, widget, dotPath) => {
if ((!widget._id) || seen.has(widget._id)) {
const _id = self.apos.util.generateId();
return self.apos.doc.db.updateOne({
_id: doc._id
}, {
$set: {
[`${dotPath}._id`]: _id
}
});
} else {
seen.add(widget._id);
}
});
});
},
addCreateWidgetOperation(operation) {
self.createWidgetOperations.push(operation);
}
};
},
helpers(self) {
return {
// Returns the rich text markup of all `@apostrophecms/rich-text` widgets
// within the provided doc or area, concatenated as a single string. In
// future this method may improve to return the content of other widgets
// that consider themselves primarily providers of rich text, such as
// subclasses of `@apostrophecms/rich-text`, which will **not** be
// regarded as a bc break. However it will never return images, videos,
// etc.
//
// By default the rich text contents of the widgets are joined with
// a newline between. You may pass your own `options.delimiter` string if
// you wish a different delimiter or the empty string. You may also pass
// an HTML element name like `div` via `options.wrapper` to wrap each
// one in a `<div>...</div>` block. Of course, there may already be a div
// in the rich txt (but then again there may not).
//
// Content will be retrieved from any widget type that supplies a
// `getRichText` method.
richText: function (within, options) {
// Use the safe filter so that the markup doesn't get double-escaped by
// nunjucks
return self.apos.template.safe(self.richText(within, options));
},
// Returns the plaintext contents of all rich text widgets
// within the provided doc or area, concatenated as a single string.
//
// By default the rich text contents of the various widgets are joined
// with a newline between. You may pass your own `options.delimiter`
// string if you wish a different delimiter or the empty string.
//
// Pass `options.limit` to limit the number of characters. This method
// will return fewer characters in order to avoid cutting off in mid-word.
//
// By default, three periods (`...`) follow a truncated string. If you
// prefer, set `options.ellipsis` to a different suffix, which may be the
// empty string if you wish.
//
// Content will be retrieved from any widget type that supplies a
// `getRichText` method.
plaintext: function (within, options) {
return self.plaintext(within, options);
},
// Returns true if the named area in the given `doc` is empty.
//
// Alternate syntax: `{ area: doc.areaname, ... more options }`
//
// An area is empty if it has no widgets in it, or when
// all of the widgets in it return true when their
// `isEmpty()` methods are interrogated. For instance,
// if an area only contains a rich text widget and that
// widget. A widget with no `isEmpty()` method is never empty.
isEmpty: function (doc, name) {
let area;
if (!name) {
area = doc.area;
} else {
area = doc[name];
}
return self.isEmpty({ area });
}
};
},
customTags(self) {
return {
area: require('./lib/custom-tags/area.js')(self),
widget: require('./lib/custom-tags/widget.js')(self)
};
}
};
function niceError(e) {
// Node.js includes the error message in the stack property, it's
// actually a complete rendering plus the stack 🤷
return e.stack;
}