UNPKG

apostrophe

Version:
1,259 lines (1,195 loc) 46.8 kB
// 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); if (options.single) { s = s.replace(/'/g, '&#39;'); } else { s = s.replace(/"/g, '&#34;'); } 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; } }; } };