diff --git a/.eslintrc b/.eslintrc index 31f483e..d350f96 100644 --- a/.eslintrc +++ b/.eslintrc @@ -1,187 +1,9 @@ +// Add our overrides as necessary { - "parser": "babel-eslint", - - "env": { - "browser": true, - "node": true, - "es6": true - }, - - "ecmaFeatures": { - "arrowFunctions": true, - "binaryLiterals": true, - "blockBindings": true, - "classes": false, - "defaultParams": true, - "destructuring": true, - "forOf": true, - "generators": true, - "modules": true, - "objectLiteralComputedProperties": true, - "objectLiteralDuplicateProperties": true, - "objectLiteralShorthandMethods": true, - "objectLiteralShorthandProperties": true, - "octalLiterals": true, - "regexUFlag": true, - "regexYFlag": true, - "spread": true, - "superInFunctions": false, - "templateStrings": true, - "unicodeCodePointEscapes": true, - "globalReturn": true, - "jsx": true - }, - + "extends": "airbnb/base", "rules": { - "block-scoped-var": [0], - "brace-style": [2, "1tbs", {"allowSingleLine": true}], - "camelcase": [0], - "comma-dangle": [0], - "comma-spacing": [2], - "comma-style": [2, "last"], - "complexity": [0, 11], - "consistent-this": [0, "that"], - "curly": [2, "multi-line"], - "default-case": [2], - "dot-notation": [2, {"allowKeywords": true}], - "eol-last": [2], - "eqeqeq": [2], - "func-names": [0], - "func-style": [0, "declaration"], - "generator-star-spacing": [2, "after"], - "guard-for-in": [0], - "handle-callback-err": [0], - "key-spacing": [2, {"beforeColon": false, "afterColon": true}], - "quotes": [2, "single", "avoid-escape"], - "max-depth": [0, 4], - "max-len": [0, 80, 4], - "max-nested-callbacks": [0, 2], - "max-params": [0, 3], - "max-statements": [0, 10], - "new-parens": [2], - "new-cap": [0], - "newline-after-var": [0], - "no-alert": [2], - "no-array-constructor": [2], - "no-bitwise": [0], - "no-caller": [2], - "no-catch-shadow": [2], - "no-cond-assign": [2], - "no-console": [0], - "no-constant-condition": [1], - "no-continue": [2], - "no-control-regex": [2], - "no-debugger": [2], - "no-delete-var": [2], - "no-div-regex": [0], - "no-dupe-args": [2], - "no-dupe-keys": [2], - "no-duplicate-case": [2], - "no-else-return": [0], - "no-empty": [2], - "no-empty-character-class": [2], - "no-eq-null": [0], - "no-eval": [2], - "no-ex-assign": [2], - "no-extend-native": [1], - "no-extra-bind": [2], - "no-extra-boolean-cast": [2], - "no-extra-semi": [1], - "no-fallthrough": [2], - "no-floating-decimal": [2], - "no-func-assign": [2], - "no-implied-eval": [2], - "no-inline-comments": [0], - "no-inner-declarations": [2, "functions"], - "no-invalid-regexp": [2], - "no-irregular-whitespace": [2], - "no-iterator": [2], - "no-label-var": [2], - "no-labels": [2], - "no-lone-blocks": [2], - "no-lonely-if": [2], - "no-loop-func": [2], - "no-mixed-requires": [0, false], - "no-mixed-spaces-and-tabs": [2, false], - "no-multi-spaces": [2], - "no-multi-str": [2], - "no-multiple-empty-lines": [2, {"max": 2}], - "no-native-reassign": [1], - "no-negated-in-lhs": [2], - "no-nested-ternary": [0], - "no-new": [2], - "no-new-func": [2], - "no-new-object": [2], - "no-new-require": [0], - "no-new-wrappers": [2], - "no-obj-calls": [2], - "no-octal": [2], - "no-octal-escape": [2], - "no-param-reassign": [2], - "no-path-concat": [0], - "no-plusplus": [0], - "no-process-env": [0], - "no-process-exit": [2], - "no-proto": [2], - "no-redeclare": [2], - "no-regex-spaces": [2], - "no-reserved-keys": [0], - "no-restricted-modules": [0], - "no-return-assign": [2], - "no-script-url": [2], - "no-self-compare": [0], - "no-sequences": [2], - "no-shadow": [2], - "no-shadow-restricted-names": [2], - "no-spaced-func": [2], - "no-sparse-arrays": [2], - "no-sync": [0], - "no-ternary": [0], - "no-throw-literal": [2], - "no-trailing-spaces": [2], - "no-undef-init": [2], - "no-undefined": [0], - "no-underscore-dangle": [2], - "no-unreachable": [2], - "no-unused-expressions": [2], - "no-unused-vars": [1, {"vars": "all", "args": "after-used"}], - "no-use-before-define": [2], - "no-void": [0], - "no-warning-comments": [0, {"terms": ["todo", "fixme", "xxx"], "location": "start"}], - "no-with": [2], - "one-var": [0], - "operator-assignment": [0, "always"], - "operator-linebreak": [2, "after"], - "padded-blocks": [0], - "quote-props": [0], - "radix": [0], - "semi": [2], - "semi-spacing": [2, {"before": false, "after": true}], - "sort-vars": [0], - "keyword-spacing": [2], - "space-before-function-paren": [2, {"anonymous": "always", "named": "never"}], - "space-before-blocks": [0, "always"], - "space-in-brackets": [ - 0, "never", { - "singleValue": true, - "arraysInArrays": false, - "arraysInObjects": false, - "objectsInArrays": true, - "objectsInObjects": true, - "propertyName": false - } - ], - "space-in-parens": [0], - "space-infix-ops": [2], - "space-unary-ops": [2, {"words": true, "nonwords": false}], - "spaced-line-comment": [0, "always"], - "strict": [2, "never"], - "use-isnan": [2], - "valid-jsdoc": [0], - "valid-typeof": [2], - "vars-on-top": [0], - "wrap-iife": [2], - "wrap-regex": [2], - "yoda": [2, "never", {"exceptRange": true}] + "no-param-reassign": 0, + "object-curly-spacing": [2, "never"], + "func-names": 0 } } diff --git a/README.md b/README.md index fa6bf26..c952b06 100644 --- a/README.md +++ b/README.md @@ -141,31 +141,42 @@ The stamp descriptor properties are made available on each stamp as `stamp.compo * `methods` - A set of methods that will be added to the object's delegate prototype. * `properties` - A set of properties that will be added to new object instances by assignment. -* `deepProperties` - A set of properties that will be added to new object instances by deep property merge, except arrays are concatenated. +* `deepProperties` - A set of properties that will be added to new object instances by deep property merge. * `propertyDescriptors` - A set of [object property descriptors](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/defineProperties) used for fine-grained control over object property behaviors. * `staticProperties` - A set of static properties that will be copied by assignment to the stamp. -* `staticDeepProperties` - A set of static properties that will be added to the stamp by deep property merge, except arrays are concatenated. +* `staticDeepProperties` - A set of static properties that will be added to the stamp by deep property merge. * `staticPropertyDescriptors` - A set of [object property descriptors](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/defineProperties) to apply to the stamp. * `initializers` - An array of functions that will run in sequence. Stamp details and arguments get passed to initializers. * `configuration` - A set of options made available to the stamp and its initializers during object instance creation. These will be copied by assignment. -* `deepConfiguration` - A set of options made available to the stamp and its initializers during object instance creation. These will be deep merged, except arrays are concatenated. +* `deepConfiguration` - A set of options made available to the stamp and its initializers during object instance creation. These will be deep merged. #### Composing Descriptors Descriptors are composed together to create new descriptors with the following rules: -* `methods` are copied by assignment as in `Object.assign()`. -* `properties` are copied by assignment as in `Object.assign()`. -* `deepProperties` are deep merged, except arrays are concatenated. -* `propertyDescriptors` are copied by assignment as in `Object.assign()`. -* `staticProperties` are copied by assignment as in `Object.assign()`. -* `staticDeepProperties` are deep merged, except arrays are concatenated -* `staticPropertyDescriptors` are copied by assignment as in `Object.assign()`. +* `methods` are copied by assignment +* `properties` are copied by assignment +* `deepProperties` are deep merged +* `propertyDescriptors` are copied by assignment +* `staticProperties` are copied by assignment +* `staticDeepProperties` are deep merged +* `staticPropertyDescriptors` are copied by assignment * `initializers` are uniquely concatenated as in `_.union()`. -* `configuration` are copied by assignment as in `Object.assign()`. -* `deepConfiguration` are deep merged, except arrays are concatenated. +* `configuration` are copied by assignment +* `deepConfiguration` are deep merged +##### Copying by assignment + +The regular `Object.assign()` is used. + +##### Deep merging + +Special deep merging algorithm should be used when merging descriptors: +* The last object type always overwrites the previous object type +* Plain objects are deeply merged (or cloned if destination metadata property is not a plain object) +* Arrays are concatenated using `Array.prototype.concat` which shallow copies elements to a new array instance +* Functions, Symbols, RegExp, etc. are copied by reference #### Priority Rules diff --git a/compose.js b/compose.js index 12976f8..10573ba 100644 --- a/compose.js +++ b/compose.js @@ -2,17 +2,69 @@ This is an example implementation of the Stamp Specifications. See https://github.com/stampit-org/stamp-specification The code is optimized to be as readable as possible. +*/ + +import {isObject, isFunction, isPlainObject, assign, uniq, isArray, merge} from 'lodash'; + +// Specification says that ARRAYS ARE NOT mergeable. They must be concatenated only. +const isMergeable = value => !isArray(value) && isObject(value); + +// Descriptor is typically a function or a plain object. But not an array! +const isDescriptor = isMergeable; + +// Stamps are functions, which have `.compose` attached function. +const isStamp = value => isFunction(value) && isFunction(value.compose); +/** + * @typedef {Function} Stamp + * @property {Function} compose + * @property {Object} [compose.methods] Instance ptototype methods + * @property {Object} [compose.properties] Instance properties + * @property {Object} [compose.deepProperties] Instance deep merged properties + * @property {Object} [compose.propertyDescriptors] JavaScript property descriptors + * @property {Object} [compose.staticProperties] Stamp static properties + * @property {Object} [compose.staticDeepProperties] Stamp deep merged static properties + * @property {Object} [compose.staticPropertyDescriptors] Stamp JavaScript property descriptors + * @property {Object} [compose.configuration] Stamp configuration + * @property {Object} [compose.deepConfiguration] Stamp deep merged configuration */ -import {mergeWith, assign, isFunction, isObject, uniq} from 'lodash'; +/** + * Mutate the 'dst' by deep merging the 'src'. Though, arrays are concatenated. + * @param {*} dst The object to deep merge 'src' into. + * @param {*} src The object to merge data from. + * @returns {*} Typically it's the 'dst' itself. Unless it was an array, or a non-mergeable. + * Can also return the 'src' itself in case the 'src' is a non-mergeable. + */ +function mergeOne(dst, src) { + // According to specification arrays must be concatenated. + // Also, the '.concat' creates a new array instance. Overrides the 'dst'. + if (isArray(src)) return (isArray(dst) ? dst : []).concat(src); -const isDescriptor = isObject; -export const merge = (dst, src) => mergeWith(dst, src, (dstValue, srcValue) => { - if (Array.isArray(dstValue)) { - if (Array.isArray(srcValue)) return dstValue.concat(srcValue); - if (isObject(srcValue)) return merge({}, srcValue); - } -}); + // Now deal with non plain 'src' object. 'src' overrides 'dst' + // Note that functions are also assigned! We do not deep merge functions. + if (!isPlainObject(src)) return src; + + // See if 'dst' is allowed to be mutated. If not - it's overridden with a new plain object. + const returnValue = isPlainObject(dst) ? dst : {}; + Object.keys(src).forEach(key => { + // Do not merge properties with the 'undefined' value. + if (src[key] === undefined) return; + // deep merge each property. Recursion! + returnValue[key] = mergeOne(returnValue[key], src[key]); + }); + return returnValue; +} + +/** + * Stamp specific deep merging algorithm. + * @param {*} dst This will be either mutated, or substituted. + * @param {Array} srcs Source bjects to merge form. + * @returns {*} Typically it's the 'dst' itself, unless it was an array or a non-mergeable. + * Or the 'src' itself if the 'src' is a non-mergeable. + */ +export function mergeDescriptor(dst, ...srcs) { + return srcs.reduce((target, src) => mergeOne(target, src), dst); +} /** * Creates new factory instance. @@ -21,22 +73,31 @@ export const merge = (dst, src) => mergeWith(dst, src, (dstValue, srcValue) => { */ function createFactory(descriptor) { return function Stamp(options = {}, ...args) { - let obj = Object.create(descriptor.methods || {}); + // The 'methods' metadata object becomes the prototype for new object instances. + const instance = Object.create(descriptor.methods || {}); - merge(obj, descriptor.deepProperties); - assign(obj, descriptor.properties); - Object.defineProperties(obj, descriptor.propertyDescriptors || {}); + // Deep merge, then override with shallow merged properties, then apply property descriptors. + merge(instance, descriptor.deepProperties); + assign(instance, descriptor.properties); + Object.defineProperties(instance, descriptor.propertyDescriptors || {}); + // Run initializers sequentially. return (descriptor.initializers || []) .filter(isFunction) - .reduce((resultingObj, initializer) => { + .reduce((resultingInstance, initializer) => { + // Invoke an initializer in the way specification tell us to. const returnedValue = initializer.call( - resultingObj, + // 'this' context will be the object instance itself + resultingInstance, + // the first argument passed from factory to initializer options, - {instance: resultingObj, stamp: Stamp, args: [options].concat(args)} + // special arguments. See specification. + {instance: resultingInstance, stamp: Stamp, args: [options].concat(args)} ); - return returnedValue === undefined ? resultingObj : returnedValue; - }, obj); + + // Any initializer can override the object instance with basically anything. + return returnedValue === undefined ? resultingInstance : returnedValue; + }, instance); }; } @@ -44,19 +105,23 @@ function createFactory(descriptor) { * Returns a new stamp given a descriptor and a compose function implementation. * @param {object} [descriptor={}] The information about the object the stamp will be creating. * @param {Function} composeFunction The "compose" function implementation. - * @returns {Function} + * @returns {Stamp} */ function createStamp(descriptor, composeFunction) { const Stamp = createFactory(descriptor); + // Deep merge, then override with shallow merged properties, then apply property descriptors. merge(Stamp, descriptor.staticDeepProperties); assign(Stamp, descriptor.staticProperties); Object.defineProperties(Stamp, descriptor.staticPropertyDescriptors || {}); + // Determine which 'compose' implementation to use. const composeImplementation = isFunction(Stamp.compose) ? Stamp.compose : composeFunction; - Stamp.compose = function () { - return composeImplementation.apply(this, arguments); + // Make a copy of the 'composeImplementation' function. + Stamp.compose = function (...args) { + return composeImplementation.apply(this, args); }; + // Assign descriptor properties to the new metadata holder. assign(Stamp.compose, descriptor); return Stamp; @@ -69,40 +134,50 @@ function createStamp(descriptor, composeFunction) { * @returns {object} Returns the dstDescriptor argument. */ function mergeComposable(dstDescriptor, srcComposable) { - const srcDescriptor = (srcComposable && srcComposable.compose) || srcComposable; + // Check if it's a stamp or something else. + const srcDescriptor = isStamp(srcComposable) ? srcComposable.compose : srcComposable; + // Ignore everything but things we can merge. if (!isDescriptor(srcDescriptor)) return dstDescriptor; const combineProperty = (propName, action) => { - if (!isObject(srcDescriptor[propName])) return; - if (!isObject(dstDescriptor[propName])) dstDescriptor[propName] = {}; + // Do not create destination properties if there is no need. + if (!isDescriptor(srcDescriptor[propName])) return; + // Check if the destination is malformed, fix the problem if any. + if (!isDescriptor(dstDescriptor[propName])) dstDescriptor[propName] = {}; + // Deep merge or shallow assign objects. action(dstDescriptor[propName], srcDescriptor[propName]); }; combineProperty('methods', assign); combineProperty('properties', assign); - combineProperty('deepProperties', merge); + combineProperty('deepProperties', mergeDescriptor); combineProperty('propertyDescriptors', assign); combineProperty('staticProperties', assign); - combineProperty('staticDeepProperties', merge); + combineProperty('staticDeepProperties', mergeDescriptor); combineProperty('staticPropertyDescriptors', assign); combineProperty('configuration', assign); - combineProperty('deepConfiguration', merge); - if (Array.isArray(srcDescriptor.initializers)) { - const initializers = (dstDescriptor.initializers || []) - .concat(srcDescriptor.initializers) - .filter(isFunction); - dstDescriptor.initializers = uniq(initializers); + combineProperty('deepConfiguration', mergeDescriptor); + + if (isArray(srcDescriptor.initializers)) { + // Initializers must be concatenated. '.concat' will also create a new array instance. + const dstInitializers = (dstDescriptor.initializers || []).concat(srcDescriptor.initializers); + // The resulting initializers array must contain functions only, and + // must not have duplicate initializers - the first occurrence wins. + dstDescriptor.initializers = uniq(dstInitializers.filter(isFunction)); } return dstDescriptor; } /** - * Given the list of composables (stamp descriptors and stamps) returns a new stamp (composable factory function). + * Given the list of composables (stamp descriptors and stamps) + * returns a new stamp (composable factory function). * @param {...(object|Function)} [composables] The list of composables. - * @returns {Function} A new stamp (aka composable factory function). + * @returns {Stamp} A new stamp (aka composable factory function). */ export default function compose(...composables) { - const descriptor = [this].concat(composables).filter(isObject).reduce(mergeComposable, {}); + // Merge metadata of all composables to a new plain object. + const descriptor = [this].concat(composables).filter(isMergeable).reduce(mergeComposable, {}); + // Recursively pass this 'compose' implementation which will be used for `Stamp.compose()` return createStamp(descriptor, compose); } diff --git a/package.json b/package.json index 4d9a089..9075aa8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "stamp-specification", - "version": "1.2.0", + "version": "1.3.0", "description": "The Composables specification and example implementation", "main": "build/compose.js", "scripts": { @@ -29,12 +29,13 @@ "homepage": "https://github.com/stampit-org/stamp-specification#readme", "devDependencies": { "babel-cli": "^6.6.5", - "babel-eslint": "^5.0.0", + "babel-eslint": "^6.1.2", "babel-polyfill": "^6.7.2", "babel-preset-es2015": "^6.6.0", "babel-register": "^6.7.2", - "check-compose": "^2.0.0", - "eslint": "~2.2.0" + "check-compose": "^3.1.2", + "eslint": "^2.13.1", + "eslint-config-airbnb": "^9.0.1" }, "dependencies": { "lodash": "^4.5.1"