apostrophe
Version:
The Apostrophe Content Management System.
1,259 lines (1,195 loc) • 46.8 kB
JavaScript
// Methods here should be of short, of universal utility, and not
// clearly in the domain of any other module. If you don't wish
// it was standard in JavaScript, it probably doesn't belong here.
// Many methods are simple wrappers for [lodash](https://npmjs.org/package/lodash) methods.
// ## Options
//
// ### `logger` (LEGACY)
//
// A function which accepts `apos` and returns an object with
// at least `info`, `debug`, `warn` and `error` methods. These methods should
// support placeholders (see `util.format`). If this option is
// not supplied, logs are simply written to the Node.js `console`.
// A `log` method may also be supplied; if it is not, `info`
// is called in its place. Calls to `apos.util.log`,
// `apos.util.error`, etc. are routed through this object
// by Apostrophe. This provides compatibility out of
// the box with many popular logging modules, including `winston`.
// NOTE: this option is deprecated, you should configure `@apostrophecms/log`
// module instead. This option will still work for BC reasons, but switching
// to structured logging and module `self.logInfo()`, `self.logError()`, etc
// is highly recommended.
// Read more in the`@apostrophecms/log` module documentation.
const _ = require('lodash');
const he = require('he');
_.str = require('underscore.string');
const XRegExp = require('xregexp').XRegExp;
const crypto = require('crypto');
const { createId } = require('@paralleldrive/cuid2');
const fs = require('fs');
const now = require('performance-now');
const Promise = require('bluebird');
const util = require('util');
const { stripIndent } = require('common-tags');
const glob = require('../../../lib/glob.js');
module.exports = {
options: {
alias: 'util',
// No more than 50 combined levels deep in:
// * relationship fetches
// * widget loaders
// * async components
stackLimit: 50
},
init(self) {
// An id for this particular Apostrophe instance that should be
// unique even in a multiple server environment.
self.apos.pid = self.generateId();
self.regExpQuote = require('regexp-quote');
self.warnedDev = {};
return self.enableLogger();
},
handlers(self) {
return {
'apostrophe:destroy': {
async destroyLogger() {
if (self.logger.destroy) {
await self.logger.destroy();
}
}
}
};
},
methods(self) {
return {
// generate a unique identifier for a new page or other object.
// IDs are generated with the cuid2 module which prevents
// collisions and easy guessing of another's ID.
generateId() {
return createId();
},
// Globally replace a string with another string.
// Regular `String.replace` does NOT offer global replace, except
// when using regular expressions, which are great but
// problematic when UTF8 characters may be present.
globalReplace(haystack, needle, replacement) {
let result = '';
while (true) {
if (!haystack.length) {
return result;
}
const index = haystack.indexOf(needle);
if (index === -1) {
result += haystack;
return result;
}
result += haystack.substr(0, index);
result += replacement;
haystack = haystack.substr(index + needle.length);
}
},
// Truncate a plaintext string at the specified number of
// characters without breaking words if possible, see
// underscore.string's prune function, of which this is
// a copy (but replacing RegExp with XRegExp for
// better UTF-8 support)
//
truncatePlaintext(str, length, pruneStr) {
if (str == null) {
return '';
}
str = String(str);
length = ~~length;
pruneStr = pruneStr != null ? String(pruneStr) : '...';
if (str.length <= length) {
return str;
}
// XRegExp is different.
const r = '.(?=W*w*$)';
const regex = new XRegExp(r, 'g');
function tmpl(c) {
return c.toUpperCase() !== c.toLowerCase() ? 'A' : ' ';
}
;
let template = str.slice(0, length + 1);
template = XRegExp.replace(template, regex, tmpl);
// 'Hello, world' -> 'HellAA AAAAA'
if (template.slice(template.length - 2).match(/\w\w/)) {
template = template.replace(/\s*\S+$/, '');
} else {
template = _.str.rtrim(template.slice(0, template.length - 1));
}
return (template + pruneStr).length > str.length
? str
: str.slice(0, template.length) + pruneStr;
},
// Escape a plaintext string correctly for use in HTML.
// If `{ pretty: true }` is in the options object,
// newlines become br tags, and URLs become links to
// those URLs. Otherwise we just do basic escaping.
//
// If `{ single: true }` is in the options object,
// single-quotes are escaped, otherwise double-quotes
// are escaped.
//
// For bc, if the second argument is truthy and not an
// object, `{ pretty: true }` is assumed.
escapeHtml(s, options) {
// for bc
if (options && typeof options !== 'object') {
options = { pretty: true };
}
options = options || {};
if (s === 'undefined') {
s = '';
}
if (typeof s !== 'string') {
s = s + '';
}
s = s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
if (options.single) {
s = s.replace(/'/g, ''');
} else {
s = s.replace(/"/g, '"');
}
if (options.pretty) {
s = s.replace(/\r?\n/g, '<br />');
// URLs to links. Careful, newlines are already <br /> at this point
s = s.replace(/https?:[^\s<]+/g, function (match) {
match = match.trim();
return '<a href="' + self.escapeHtml(match) + '">' + match + '</a>';
});
}
return s;
},
// Convert HTML to true plaintext, with all entities decoded.
htmlToPlaintext(html) {
// The awesomest HTML renderer ever (look out webkit):
// block element opening tags = newlines, closing tags and
// non-container tags just gone
html = html.replace(/<\/.*?>/g, '');
html = html.replace(/<(h1|h2|h3|h4|h5|h6|p|br|blockquote|li|article|address|footer|pre|header|table|tr|td|th|tfoot|thead|div|dl|dt|dd).*?>/gi, '\n');
html = html.replace(/<.*?>/g, '');
return he.decode(html);
},
// Capitalize the first letter of a string.
capitalizeFirst(s) {
return s.charAt(0).toUpperCase() + s.substr(1);
},
// Convert other name formats such as underscore and camelCase to a
// hyphenated css-style name.
cssName(camel) {
// Keep in sync with client side version
let i;
let css = '';
let dash = false;
for (i = 0; i < camel.length; i++) {
let c = camel.charAt(i);
const lower = c >= 'a' && c <= 'z';
const upper = c >= 'A' && c <= 'Z';
const digit = c >= '0' && c <= '9';
if (!(lower || upper || digit)) {
dash = true;
continue;
}
if (upper) {
if (i > 0) {
dash = true;
}
c = c.toLowerCase();
}
if (dash) {
css += '-';
// Preserve double dash if they were literal dashes.
// This is common in modern CSS patterns
if (i >= 2 && camel.substr(i - 2, 2) === '--') {
css += '-';
}
dash = false;
}
css += c;
}
return css;
},
// Convert a name to camel case.
//
// Useful in converting CSV with friendly headings into sensible property
// names.
//
// Only digits and ASCII letters remain.
//
// Anything that isn't a digit or an ASCII letter prompts the next
// character to be uppercase. Existing uppercase letters also trigger
// uppercase, unless they are the first character; this preserves existing
// camelCase names.
camelName(s) {
// Keep in sync with client side version
let i;
let n = '';
let nextUp = false;
for (i = 0; i < s.length; i++) {
const c = s.charAt(i);
// If the next character is already uppercase, preserve that, unless
// it is the first character
if (i > 0 && c.match(/[A-Z]/)) {
nextUp = true;
}
if (c.match(/[A-Za-z0-9]/)) {
if (nextUp) {
n += c.toUpperCase();
nextUp = false;
} else {
n += c.toLowerCase();
}
} else {
nextUp = true;
}
}
return n;
},
// Add a slash to a path, but only if it does not already end in a slash.
addSlashIfNeeded(path) {
path += '/';
path = path.replace(/\/\/$/, '/');
return path;
},
// Perform an md5 checksum on a string. Returns hex string.
md5(s) {
const md5 = crypto.createHash('md5');
md5.update(s);
return md5.digest('hex');
},
// perform an md5 checksum on a file. Async. Returns a hex string.
async md5File(filename) {
return new Promise(function (resolve, reject) {
const md5 = crypto.createHash('md5');
const s = fs.ReadStream(filename);
s.on('data', function (d) {
md5.update(d);
});
s.on('error', function (err) {
return reject(err);
});
s.on('end', function () {
const d = md5.digest('hex');
return resolve(d);
});
});
},
// Get file size in bytes. Async. Returns size.
async fileLength(filename) {
const stat = await Promise.promisify(function (name, cb) {
return fs.stat(name, cb);
})(filename);
return stat.size;
},
// Turn the provided string into a string suitable for use as a slug.
// ONE punctuation character normally forbidden in slugs may
// optionally be permitted by specifying it via options.allow.
// The separator may be changed via options.separator.
// By default, the i18n.options.stripUrlAccents option is honored;
// having stripAccents passed as an option takes precedence.
slugify(s, options) {
const { stripAccents, ...opts } = options || {};
const slug = require('sluggo')(s, opts);
const shouldStripAccents = (typeof stripAccents !== 'undefined')
? stripAccents
: self.apos.i18n.options.stripUrlAccents;
if (shouldStripAccents) {
return _.deburr(slug);
}
return slug;
},
// Returns a string that, when used for indexes, behaves
// similarly to MySQL's default behavior for sorting, plus a little
// extra tolerance of punctuation and whitespace differences. This is
// in contrast to MongoDB's default "absolute match with same case only"
// behavior which is no good for most practical purposes involving text.
//
// The use of this method to create sortable properties like
// "titleSortified" is encouraged. It should not be used for full text
// search, as MongoDB full text search is now available (see the
// "search" option to apos.get and everything layered on top of it).
// It is however used as part of our "autocomplete" search implementation.
sortify(s) {
return self.slugify(s, { separator: ' ' });
},
// Turns a user-entered search query into a regular expression.
// If the string contains multiple words, at least one space is
// required between them in matching documents, but additional words
// may also be skipped between them, up to a reasonable limit to
// preserve performance and avoid useless strings.
//
// Although we now have MongoDB full text search this is still
// a highly useful method, for instance to locate autocomplete
// candidates via highSearchWords.
//
// If the prefix flag is true the search matches only at the start.
searchify(q, prefix) {
q = self.sortify(q);
if (prefix) {
q = '^' + q;
}
q = q.replace(/ /g, ' .{0,20}?');
q = new RegExp(q);
return q;
},
// Clone the given object recursively, discarding all
// properties whose names begin with `_` except
// for `_id`. Returns the clone.
//
// This removes the output of relationships and
// other dynamic loaders, so that dynamically available
// content is not stored redundantly in MongoDB.
//
// If the object is an array, the clone is also an array.
//
// Date objects are cloned as such. All other non-JSON
// objects are cloned as plain JSON objects.
//
// If `keepScalars` is true, properties beginning with `_`
// are kept as long as they are not objects. This is useful
// when using `clonePermanent` to limit JSON inserted into
// browser attributes, rather than filtering for the database.
// Preserving simple string properties like `._url` is usually
// a good thing in the former case.
//
// Arrays are cloned as such only if they are true arrays
// (Array.isArray returns true). Otherwise all objects with
// a length property would be treated as arrays, which is
// an unrealistic restriction on apostrophe doc schemas.
clonePermanent(o, keepScalars) {
let c;
const isArray = Array.isArray(o);
if (isArray) {
c = [];
} else {
c = {};
}
function iterator(val, key) {
// careful, don't crash on numeric keys
if (typeof key === 'string') {
if (key.charAt(0) === '_' && key !== '_id') {
if (!keepScalars || typeof val === 'object') {
return;
}
}
}
if (val === null || val === undefined) {
// typeof(null) is object, sigh
c[key] = null;
} else if (typeof val !== 'object') {
c[key] = val;
} else if (val instanceof Date) {
c[key] = new Date(val);
} else {
c[key] = self.clonePermanent(val, keepScalars);
}
}
if (isArray) {
_.each(o, iterator);
} else {
_.forOwn(o, iterator);
}
return c;
},
// `ids` should be an array of mongodb IDs. The elements of the `items`
// array, which should be the result of a mongodb query, are returned in
// the order specified by `ids`. This is useful after performing an `$in`
// query with MongoDB (note that `$in` does NOT sort its strings in the
// order given).
//
// Any IDs that do not actually exist for an item in the `items` array
// are not returned, and vice versa. You should not assume the result will
// have the same length as either array.
//
// Optionally you may specify a property name other than _id as the third
// argument. You may use dot notation in this argument.
orderById(ids, items, idProperty) {
if (idProperty === undefined) {
idProperty = '_id';
}
const byId = {};
_.each(items, function (item) {
let value = item;
const keys = idProperty.split('.');
_.each(keys, function (key) {
value = value[key];
});
byId[value] = item;
});
items = [];
_.each(ids, function (_id) {
if (_.has(byId, _id)) {
items.push(byId[_id]);
}
});
return items;
},
// Return true if `req` is an AJAX request (`req.xhr` is set, or
// `req.query.xhr` is set to emulate it) and Apostrophe's
// main content area refresh mechanism is not in play.
isAjaxRequest(req) {
return (req.xhr || req.query.xhr) && (req.query.aposRefresh !== '1');
},
// Sort the given array of strings in place, comparing strings in a
// case-insensitive way.
insensitiveSort(strings) {
strings.sort(self.insensitiveSortCompare);
},
// Sort the given array of objects in place, based on the value of the
// given property of each object, in a case-insensitive way.
insensitiveSortByProperty(objects, property) {
objects.sort(function (a, b) {
return self.insensitiveSortCompare(a[property], b[property]);
});
},
// Compare two strings in a case-insensitive way, returning -1, 0 or 1,
// suitable for use with sort(). If the two strings represent numbers,
// compare them as numbers for a natural sort order when comparing strings
// like '4' and '10'.
insensitiveSortCompare(a, b) {
let na, nb;
if (a && a.toString().toLowerCase()) {
a = a.toString().toLowerCase();
}
if (b && b.toString().toLowerCase()) {
b = b.toString().toLowerCase();
}
// Sort naturally when comparing two numbers,
// so the relative order of 10 and 2 makes sense
if (a.match(/^[\d.]+$/) && b.match(/^[\d.]+/)) {
na = parseFloat(a);
nb = parseFloat(b);
if (!(isNaN(na) || isNaN(nb))) {
a = na;
b = nb;
}
}
if (a < b) {
return -1;
} else if (a > b) {
return 1;
} else {
return 0;
}
},
// Within the given object (typically a doc or widget),
// find a subobject with the given `_id` property.
// Can be nested at any depth.
//
// Useful to locate a specific widget within a doc.
findNestedObjectById(object, _id, { ignoreDynamicProperties = false } = {}) {
let key;
let val;
let result;
for (key in object) {
if (ignoreDynamicProperties && (key.charAt(0) === '_')) {
continue;
}
val = object[key];
if (val && typeof val === 'object') {
if (val._id === _id) {
return val;
}
result = self.findNestedObjectById(val, _id);
if (result) {
return result;
}
}
}
},
// Within the given object (typically a doc or widget),
// find a subobject with the given `_id` property.
// Can be nested at any depth.
//
// Useful to locate a specific widget within a doc.
//
// Returns an object like this: `{ object: { ... }, dotPath:
// 'dot.path.of.object' }`
//
// Ignore the `_dotPath` argument to this method; it is used for
// recursion.
findNestedObjectAndDotPathById(
object,
id,
{ ignoreDynamicProperties = false } = {},
_dotPath
) {
let key;
let val;
let result;
let subPath;
_dotPath = _dotPath || [];
for (key in object) {
if (ignoreDynamicProperties && (key.charAt(0) === '_')) {
continue;
}
val = object[key];
if (val && typeof val === 'object') {
subPath = _dotPath.concat(key);
if (val._id === id) {
return {
object: val,
dotPath: subPath.join('.')
};
}
result = self.findNestedObjectAndDotPathById(
val,
id,
{ ignoreDynamicProperties },
subPath
);
if (result) {
return result;
}
}
}
},
enableLogger() {
// Legacy, configured via this module.
if (self.options.logger) {
self.logger = self.options.logger(self.apos);
return;
}
// New, configured via the `log` module.
const logOpts = self.apos.structuredLog.options;
if (logOpts.logger) {
self.logger = typeof logOpts.logger === 'function'
? logOpts.logger(self.apos)
: logOpts.logger;
return;
}
self.logger = require('./lib/logger.js')(self.apos);
},
// Log a message. The default
// implementation wraps `console.log` and passes on
// all arguments, so substitution strings may be used.
//
// Overrides should be written with support for
// substitution strings in mind. See the
// `console.log` documentation.
//
// If the logger has no `log` method, the `info` method
// is used. This allows an instance of `bole` or similar
// to be used directly.
log(...args) {
// kept for bc
if (!self.logger.log) {
return self.logger.info(...self.convertLegacyLogPayload(args));
}
self.logger.log(...self.convertLegacyLogPayload(args));
},
// Log an informational message. The default
// implementation wraps `console.info` and passes on
// all arguments, so substitution strings may be used.
//
// Overrides should be written with support for
// substitution strings in mind. See the
// `console.log` documentation.
info(...args) {
self.logger.info(...self.convertLegacyLogPayload(args));
},
// Log a debug message. The default implementation wraps
// `console.debug` if available, otherwise `console.log`,
// and passes on all arguments, so substitution strings may be used.
//
// Overrides should be written with support for
// substitution strings in mind. See the
// `console.warn` documentation.
debug(...args) {
self.logger.debug(...self.convertLegacyLogPayload(args));
},
// Log a warning. The default implementation wraps
// `console.warn` and passes on all arguments,
// so substitution strings may be used.
//
// Overrides should be written with support for
// substitution strings in mind. See the
// `console.warn` documentation.
//
// The intention is that `apos.util.warn` should be
// called for situations less dire than
// `apos.util.error`.
warn(...args) {
self.logger.warn(...self.convertLegacyLogPayload(args));
},
// Identical to `apos.util.warn`, except that (1) the warning is
// not displayed if `process.env.NODE_ENV` is `production`, and
// (2) if the warning's first argument is a message it is
// automatically prefixed with a warning icon.
//
// Also see `warnDevOnce` which is less likely to irritate
// the developer until they stop paying attention.
warnDev(...args) {
if (process.env.NODE_ENV === 'production') {
return;
}
const m = args[0];
if ((typeof m) === 'string') {
args[0] = `⚠️ ${m}`;
}
self.warn(...args);
},
// Identical to `apos.util.warnDev`, except that the warning is
// only displayed once per `name` unless the command line
// option `--all-[name]` is present. Nothing appears if
// `process.env.NODE_ENV` is `production`, unless
// `--all-[name]` is present on the command line. You can
// also suppress these with `--ignore-[name]`.
warnDevOnce(name, ...args) {
const always = self.apos.argv[`all-${name}`];
const hide = self.apos.argv[`ignore-${name}`];
if (hide) {
return;
}
if ((process.env.NODE_ENV === 'production') && (!always)) {
return;
}
if (always || (!self.warnedDev[name])) {
self.warnDev(...args);
if (!always) {
self.warnedDev[name] = true;
self.info(stripIndent`
This warning appears only once to save space. Pass --all-${name}
on the command line to see the warning for all cases.
` + '\n');
}
}
},
// Log an error message. The default implementation
// wraps `console.error` and passes on all arguments,
// so substitution strings may be used.
//
// Overrides should be written with support for
// substitution strings in mind. See the
// `console.error` documentation.
error(...args) {
self.logger.error(...self.convertLegacyLogPayload(args));
},
// Performance profiling method. At the start of the operation you want
// to profile, call with req (may be null or omitted entirely) and a
// dot-namespaced key. A function is returned. Call that function
// with no arguments at the end of your operation.
//
// Alternatively, you may pass the duration in milliseconds yourself as
// the third argument. In this case no function is returned. This is
// useful if you are already gathering timing information for other
// purposes.
//
// Profiler modules such as `@apostrophecms/profiler` override this
// method to provide detailed performance analysis. Note that they must
// support both calling syntaxes. The default implementation does nothing.
//
// If the dot-separated key looks like
// `callAll.pageBeforeSend.module-name`, time is tracked to `callAll`,
// `callAll.pageBeforeSend`, and `callAll.pageBeforeSend.module-name` as
// categories. Note that the most general category should come first.
//
// To avoid overhead and bloat in the core, the default implementation
// does nothing. Also most core modules and methods do not invoke this
// method. However, the `@apostrophecms/profiler` module extends them to
// invoke it, for performance reasons: the profiler itself can have a
// performance overhead.
profile(req, key, optionalDuration) {
if (typeof req === 'string') {
// Called without a `req`
if (arguments.length === 2) {
return self.profile(null, arguments[0], arguments[1]);
} else {
return self.profile(null, arguments[0]);
}
}
if (arguments.length === 2) {
// You did not supply duration so we return a function
// for you to call at the end
return function () {
};
}
},
// Returns time since the start of the process in milliseconds,
// with high-resolution accuracy. Used by apos.util.profile.
// See: https://www.npmjs.com/package/performance-now
now() {
return now();
},
// Given a widget or doc, return the appropriate manager module. If the manager
// cannot be determined for any reason, undefined is returned.
getManagerOf(object) {
if (object.metaType === 'doc') {
return self.apos.doc.getManager(object.type);
} else if (object.metaType === 'widget') {
return self.apos.area.getWidgetManager(object.type);
} else if (object.metaType === 'arrayItem') {
return self.apos.schema.getArrayManager(object.scopedArrayName);
} else if (object.metaType === 'object') {
return self.apos.schema.getObjectManager(object.scopedObjectName);
} else {
self.apos.util.error(`Unsupported metaType in getManagerOf: ${object.metaType}`);
return undefined;
}
},
// fetch the value at the given path from the object or
// array `o`. `path` supports dot notation like MongoDB, and
// in addition if the first component begins with `@xyz` the
// sub-object within `o` with an `_id` property equal to `xyz`.
// is found and returned, no matter how deeply nested it is.
get(o, path) {
let i;
path = path.split('.');
for (i = 0; (i < path.length); i++) {
const p = path[i];
if ((i === 0) && (p.charAt(0) === '@')) {
o = self.apos.util.findNestedObjectById(o, p.substring(1));
} else {
o = o[p];
}
}
return o;
},
// Returns `path` with any @ reference present resolved to a full
// dot path. If the reference cannot be resolved the @ reference
// remains in the returned value.
resolveAtReference(o, path) {
path = path.split('.');
if (path[0] && (path[0].charAt(0) === '@')) {
const info = self.apos.util
.findNestedObjectAndDotPathById(o, path[0].substring(1));
if (!info) {
return path.join('.');
}
if (path.length > 1) {
return info.dotPath + '.' + (path.slice(1).join('.'));
} else {
return info.dotPath;
}
} else {
return path.join('.');
}
},
// set the value at the given path within the object or
// array `o`. `path` supports dot notation like MongoDB. In
// addition if the first component begins with `@xyz` the
// sub-object within `o` with an `_id` property equal to `xyz`.
// is located, no matter how deeply nested it is. If that is
// the only component of the path the sub-object is replaced
// with v. If there are further components via dot notation,
// they are honored to locate the final location for `v`.
//
// The `@` syntax works only for locating sub-objects. You may
// not pass `@abc` where `abc` is the `_id` of `o` itself.
set(o, path, v) {
let i;
let p;
let matches;
if (path.charAt(0) === '@') {
matches = path.match(/^@([^.]+)(.*)$/);
if (!matches) {
throw new Error(`@ syntax used without an id: ${path}`);
}
const found = self.apos.util.findNestedObjectAndDotPathById(
o,
matches[1],
{ ignoreDynamicProperties: true }
);
if (found) {
if (matches[2].length) {
o = found.object;
path = matches[2].substring(1);
} else {
path = found.dotPath;
}
} else {
return;
}
}
path = path.split('.');
for (i = 0; (i < (path.length - 1)); i++) {
p = path[i];
o = o[p];
}
p = path[i];
o[p] = v;
},
// Pushes the given label onto `req.aposStack` before awaiting the given
// function; then pops the label off the stack and returns the result of
// the function. If the stack limit is reached, a warning which includes
// the stack itself is printed to assist in debugging, and the return
// value is `undefined`. Code that calls this function should be prepared
// not to crash if `undefined` is returned.
async recursionGuard(req, label, fn) {
req.aposStack.push(label);
if (req.aposStack.length === self.options.stackLimit) {
self.apos.util.warn(stripIndent`
WARNING: reached the maximum depth of Apostrophe's asynchronous stack.
This is usually because widget loaders, async components, and/or relationships
are causing an infinite loop.
Please review the stack to find the problem:
${req.aposStack.join('\n')}
Suggested fixes:
* For each relationship, set "areas: false" or configure a projection with
"project".
* Use the "neverLoad" option in your widget modules to block them from loading
particular widget types recursively.
* Do not use "neverLoadSelf: false" for any widget type unless you can guarantee
it will never cause an infinite loop.
* Make sure your async components do not call themselves recursively in a way
that will never terminate.
`);
req.aposStack.pop();
return;
}
const result = await fn();
req.aposStack.pop();
return result;
},
// Returns a new `req` object with the properties of the original plus any
// in the optional `properties` parameter. Used when a request object
// with one change is desired, such as `mode: 'published'`. Avoids
// the need to push and pop properties of the original `req`.
// Also available as `req.clone(properties = {})`.
cloneReq(req, properties = {}) {
// Express and Node.js offer no official constructor for a req object.
// But a plain object "clone" only takes us so far because we need
// access to things like `req.get`. Solution: ask `Object.create` to
// create a new object with the same prototype as `req`, then copy the
// own properties of `req` into the new object.
const result = Object.assign(
Object.create(Object.getPrototypeOf(req)), req, properties
);
self.apos.i18n.setPrefixUrls(result);
// Must have its own clone function or we can't clone two levels deep
result.clone = (properties = {}) => {
return self.apos.util.cloneReq(result, properties);
};
return result;
},
pipe: (...functions) => (initial) => functions
.reduce((accumulator, current) => current(accumulator), initial),
merge(...objects) {
const concatArrays = (objValue, srcValue) => {
if (Array.isArray(objValue)) {
return objValue.concat(srcValue);
}
};
return _.mergeWith({}, ...objects, concatArrays);
},
omit(source, keys) {
return _.omit(source, keys);
},
// A wrapper for glob 10.x with the sorting semantics of glob
// 8.x, as a drop-in replacement to avoid introducing bc issues
// in existing Apostrophe logic. Not guaranteed to cover
// every difference between glob 8.x and 10.x, just those
// that break Apostrophe startup
glob(pattern, options = {}) {
return glob(pattern, options);
},
// Internal method. Attempt to convert the log payload to an object
// for legacy calls and when `@apostrophecms/log` has been configured
// with `messageAs: 'someKey'`.
// This change is backwards compatible with the previous behavior because
// `messageAs` is a newly introduced option. Custom loggers should adapt
// to this change when using `messageAs`.
// `args` is the argument array passed to the any log method.
// The result (when required) is an array with a single object.
// First string argument (if available) is used as `message`.
// First object argument is used as result object.
// All other arguments are passed as `args` property of the result object.
convertLegacyLogPayload(args) {
const messageAs = self.apos.structuredLog.options.messageAs;
if (!messageAs || args.length === 0) {
return args;
}
// Already formatted by the structured log module. Nothing we can do if
// not.
if (args.length === 1 && _.isPlainObject(args[0])) {
return args;
}
// Should also handle apos.util.warnDev() calls.
const messageIndex = args
.findIndex(arg => typeof arg === 'string' && arg.trim() && arg !== '\n⚠️ ');
const firstObjectIndex = args.findIndex(arg => _.isPlainObject(arg));
const message = messageIndex !== -1 ? args[messageIndex] : null;
const firstObject = firstObjectIndex !== -1 ? { ...args[firstObjectIndex] } : {};
if (message) {
firstObject[messageAs] = message;
}
const rest = args
.filter((arg, index) => ![ messageIndex, firstObjectIndex ].includes(index));
if (rest.length) {
firstObject.args = rest;
}
return [ firstObject ];
}
};
},
helpers(self) {
return {
// Turn the provided string into a string suitable for use as a slug.
// ONE punctuation character normally forbidden in slugs may
// optionally be permitted by specifying it via options.allow.
// The separator may be changed via options.separator.
// By default, the i18n.options.stripUrlAccents option is honored;
// having stripAccents passed as an option takes precedence.
slugify: function(string, options) {
return self.slugify(string, options);
},
// Log a message from a Nunjucks template. Great for debugging.
// Outputs nothing to the template. Invokes apos.util.log,
// which by default invokes console.log.
log: function(msg) {
self.log.apply(self.apos, arguments);
return '';
},
// Log the properties of the given object in detail.
// Invokes `util.inspect` on the given object, down to a
// depth of 10. Outputs nothing to the template.
inspect: function(o) {
self.log(util.inspect(o, { depth: 10 }));
return '';
},
// Generate a globally unique ID
generateId: function() {
return self.generateId();
},
// Test whether the specified date object refers to a date in the current
// year. The events module utilizes this
isCurrentYear: function(date) {
const now = new Date();
return date.getYear() === now.getYear();
},
// check if something is properly undefined
isUndefined: function(o) {
return (o === undefined);
},
// check if something is strictly equal to false
isFalse: function(o) {
return (o === false);
},
// Convert string to start case (make default labels out of camelCase
// property names)
startCase: function(o) {
return _.startCase(o);
},
// check if something is a function (as opposed to property)
isFunction: function(o) {
return (typeof o === 'function');
},
// make up for lack of triple equals
eqStrict: function(a, b) {
return (a === b);
},
// Returns true if the list contains the specified value.
// If value is an array, returns true if the list contains
// *any of* the specified values
contains: function(list, value) {
if (_.isArray(value)) {
for (let i = 0; i < value.length; i++) {
const valueItem = value[i];
if (_.includes(list, valueItem)) {
return true;
}
}
return false;
} else {
return _.includes(list, value);
}
},
// Returns true if the list contains at least one
// object with the named property.
// The first parameter may also be a single object, in
// which case this function returns true if that object
// has the named property.
containsProperty: function(list, property) {
if (_.isArray(list)) {
return _.some(list, function(item) {
return _.has(item, property);
});
} else {
return _.has(list, property);
}
},
// Reverses the order of the array. This MODIFIES the array
// in addition to returning it
reverse: function(array) {
return array.reverse();
},
// If the `list` argument is a string, returns true if it begins
// with `value`. If the `list` argument is an array, returns
// true if at least one of its elements begins with `value`.
beginsWith: function(list, value) {
if (_.isArray(list)) {
for (let i = 0; i < list.length; i++) {
const listItem = list[i];
if (listItem.indexOf(value) === 0) {
return true;
}
}
} else {
if (list.indexOf(value) === 0) {
return true;
}
}
return false;
},
// Find the first array element, if any, that has the specified value for
// the specified property.
find: function(arr, property, value) {
return _.find(arr, function(item) {
return (item[property] === value);
});
},
// Find all the array elements, if any, that have the specified value for
// the specified property.
filter: function(arr, property, value) {
return _.filter(arr, function(item) {
return (item[property] === value);
});
},
// Reject array elements that have the specified value for
// the specified property.
reject: function(arr, property, value) {
return _.reject(arr, function(item) {
return (item[property] === value);
});
},
// Find all the array elements, if any, for which the specified property
// is truthy.
filterNonempty: function(arr, property) {
return _.filter(arr, function(item) {
return (item[property]);
});
},
// Find all the array elements, if any, for which the specified property
// is not truthy.
filterEmpty: function(arr, property) {
return _.filter(arr, function(item) {
return (!item[property]);
});
},
// Returns true if the specified array or object is considered empty.
// Objects are empty if they have no own enumerable properties.
// Arrays are considered empty if they have a length of 0.
isEmpty: function(item) {
return _.isEmpty(item);
},
// Given an array of objects with the given property, return an array with
// the value of that property for each object.
pluck: function(arr, property) {
return _.map(arr, property);
},
// Given an object, return an object without
// the named properties or array of named
// properties (see _.omit()).
omit: function(object, property /* , property... */) {
return _.omit.apply(_, arguments);
},
// Given the arrays `array` and `without`, return
// only the elements of `array` that do not occur
// in `without`. If `without` is not an array it is
// treated as an empty array.
//
// If `property` is present, then that property of
// each element of array is compared to elements
// of `without`. This is useful when `array` contains
// full-blown choices with a `value` property, while
// `without `just contains actual values.
//
// A deep comparison is performed with `_.isEqual`.
difference: function(array, without, property) {
return _.filter(Array.isArray(array) ? array : [], function(item) {
return !_.find(without || [], function(other) {
if (property) {
return _.isEqual(item[property], other);
} else {
return _.isEqual(item, other);
}
});
});
},
// Concatenate all of the given arrays and/or values
// into a single array. If an argument is an array, all
// of its elements are individually added to the
// resulting array. If an argument is a value, it is
// added directly to the array.
concat: function(arrOrObj1, arrOrObj2 /* , ... */) {
// I tried to implement this with call() but I kept
// getting arrays in my values. We still get the
// benefit of concat() and its built-in support for
// treating arrays and scalar values differently. -Tom
let result = [];
let i;
for (i = 0; (i < arguments.length); i++) {
result = result.concat(arguments[i]);
}
return result;
},
// Groups by the property named by 'key' on each of the values.
// If the property referred to by the string 'key' is found to be
// an array property of the first object, apos.util.groupByArray is
// called.
//
// Usage: {{ apos.util.groupBy(people, 'age') }} or {{
// apos.util.groupBy(items, 'colors') }}
groupBy: function(items, key) {
if (items.length && Array.isArray(items[0][key])) {
return groupByArray(items, key);
}
return _.groupBy(items, key);
function groupByArray(items, arrayName) {
const results = {};
// looping over each item in the original array
_.each(items, function(item) {
// looping over each item in the array within the top level item
_.each(item[arrayName] || [], function(inner) {
if (!results[inner]) {
results[inner] = [];
}
// grouping top level items on the sub properties
results[inner].push(item);
});
});
return results;
}
},
// Given a series of alternating keys and values, this
// function returns an object with those values for
// those keys. For instance, apos.util.object('name', 'bob')
// returns { name: 'bob' }. This is useful because
// Nunjucks does not allow you to create an object with
// a property whose name is unknown at the time the
// template is written.
object: function(/* key, value, ... */) {
const o = {};
let i = 0;
while (i < arguments.length) {
o[arguments[i]] = arguments[i + 1];
i += 2;
}
return o;
},
// Pass as many objects as you want; they will get merged via
// `_.merge` into a new object, without modifying any of them, and
// the resulting object will be returned. If several objects have
// a property, the last object wins.
//
// This is useful to add one more option to an options object
// which was passed to you.
//
// If any argument is null, it is skipped gracefully. This allows
// you to pass in an options object without checking if it is null.
merge: function() {
let result = {};
let i;
for (i = 0; (i < arguments.length); i++) {
if (!arguments[i]) {
continue;
}
result = _.merge(result, arguments[i]);
}
return result;
}
};
}
};