-
Notifications
You must be signed in to change notification settings - Fork 601
/
Copy pathindex.js
819 lines (790 loc) · 31.1 KB
/
index.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
const _ = require('lodash');
const { stripIndent } = require('common-tags');
// 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' },
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.widgetOperations = [];
self.enableBrowserData();
self.addDeduplicateWidgetIdsMigration();
},
apiRoutes(self) {
return {
post: {
async renderWidget(req) {
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 _docId = self.apos.launder.id(req.body._docId);
const field = self.apos.schema.getFieldById(areaFieldId);
const livePreview = self.apos.launder.boolean(req.body.livePreview);
if (!field) {
throw self.apos.error('invalid');
}
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');
}
try {
widget = await sanitize(widget);
} catch (e) {
if (livePreview) {
return 'aposLivePreviewSchemaNotYetValid';
}
throw e;
}
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 sanitize(widget) {
return manager.sanitize(req, widget, options);
}
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);
// 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);
}
});
const result = {
...req.data,
options,
widget
};
return result;
}
return self.renderWidget(req, type, widget, options);
}
}
}
};
},
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) {
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];
},
// 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 content but is not configured');
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`.
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 });
}
if (inline) {
for (const item of area.items) {
item._rendered = await self.renderWidget(
req,
item.type,
item,
widgets[item.type]
);
}
return null;
}
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
});
},
// 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) {
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 documents.
const regex = /^_(ancestors|children)|\._(ancestors|children)/;
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, opts) {
const preppedArea = self.prepForRender(area, context, path);
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 = _.find(schema, '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;
}
});
},
addWidgetOperation(operation) {
if (!operation.name || !operation.label || !operation.modal) {
throw self.apos.error('invalid', 'addWidgetOperation requires name, label and modal properties.');
}
if (operation.secondaryLevel !== true && !operation.icon) {
throw self.apos.error('invalid', 'addWidgetOperation requires the icon property at primary level.');
}
self.widgetOperations = self.widgetOperations.filter(
({ name }) => name !== operation.name
);
self.widgetOperations.push(operation);
},
getBrowserData(req) {
const widgets = {};
const widgetEditors = {};
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 && browserData.components && browserData.components.widget) || 'AposWidget';
widgetEditors[name] = (browserData && browserData.components && browserData.components.widgetEditor) || '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 || {};
});
const widgetOperations = self.widgetOperations.filter(({ permission }) => {
if (permission?.action && permission?.type) {
return self.apos.permission.can(req, permission.action, permission.type, permission.mode || 'draft');
}
return true;
});
return {
components: {
editor: 'AposAreaEditor',
widgets,
widgetEditors
},
widgetIsContextual,
widgetHasPlaceholder,
widgetHasInitialModal,
widgetPreview,
contextualWidgetDefaultData,
widgetManagers,
action: self.action,
widgetOperations
};
},
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);
}
});
});
}
};
},
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)
};
}
};