stampit
Version:
Create objects from reusable, composable behaviors.
365 lines (313 loc) • 12.4 kB
JavaScript
function isFunction(obj) {
return typeof obj === "function";
}
function isObject(obj) {
return (obj && typeof obj === "object") || isFunction(obj);
}
function isPlainObject(value) {
return value && typeof value === "object" && value.__proto__ === Object.prototype;
}
/**
* Returns true if argument is a Stamp.
* @param {*} obj Any object
* @returns {Boolean} True is the obj is a Stamp
*/
function isStamp(obj) {
return isFunction(obj) && isFunction(obj.compose);
}
function getOwnPropertyKeys(obj) {
return [...Object.getOwnPropertyNames(obj), ...Object.getOwnPropertySymbols(obj)];
}
/**
* Unlike Object.assign(), our assign() copies symbols, getters and setters.
* @param {Object} dst Must be an object. Otherwise throws.
* @param {Object} [src] Can be falsy
* @returns {Object} updated 'dst'
*/
function assignOne(dst, src) {
if (src) {
// We need to copy regular props, symbols, getters and setters.
for (const key of getOwnPropertyKeys(src)) {
const desc = Object.getOwnPropertyDescriptor(src, key);
// Make it rewritable because two stamps can have same named getter/setter
Object.defineProperty(dst, key, desc);
}
}
return dst;
}
/**
* Unlike _.merge(), our merge() copies symbols, getters and setters.
* The 'src' argument plays the command role.
* The returned values is always of the same type as the 'src'.
* @param {Array|Object|*} dst Destination
* @param {Array|Object|*} src Source
* @returns {Array|Object|*} The `dst` argument
*/
function mergeOne(dst, src) {
if (src === undefined) return dst;
// According to specification arrays must be concatenated.
// Create a new array instance. Overrides the 'dst'.
if (Array.isArray(src)) {
if (Array.isArray(dst)) return [...dst, ...src];
return [...src]; // ignore the 'dst', clone the src
}
// 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;
for (const key of getOwnPropertyKeys(src)) {
const desc = Object.getOwnPropertyDescriptor(src, key);
if (desc.hasOwnProperty("value")) {
// is this a regular property?
// Do not merge properties with the 'undefined' value.
if (desc.value !== undefined) {
// deep merge each property. Recursion!
dst[key] = mergeOne(isPlainObject(dst[key]) || Array.isArray(src[key]) ? dst[key] : {}, src[key]);
}
} else {
// nope, it looks like a getter/setter
// Make it rewritable because two stamps can have same named getter/setter
Object.defineProperty(dst, key, desc);
}
}
return dst;
}
const assign = (dst, ...args) => args.reduce(assignOne, dst);
const merge = (dst, ...args) => args.reduce(mergeOne, dst);
function extractUniqueFunctions(...args) {
const funcs = new Set(args.flat().filter(isFunction));
return funcs.size ? [...funcs] : undefined;
}
/**
* Creates new factory instance.
* @returns {Function} The new factory function.
*/
function createEmptyStamp() {
return function Stamp(...args) {
let options = args[0];
const descriptor = Stamp.compose || {};
// Next line was optimized for most JS VMs. Please, be careful here!
// The instance of this Stamp
let instance = descriptor.methods ? Object.create(descriptor.methods) : {};
mergeOne(instance, descriptor.deepProperties);
assignOne(instance, descriptor.properties);
if (descriptor.propertyDescriptors) Object.defineProperties(instance, descriptor.propertyDescriptors);
const inits = descriptor.initializers;
// No initializers?
if (!Array.isArray(inits) || inits.length === 0) return instance;
// The spec. says that the first argument to every initializer must be an
// empty object if nothing else was given when a Stamp was called: Stamp()
if (options === undefined) options = {};
for (let i = 0, initializer, returnedValue; i < inits.length; ) {
initializer = inits[i++];
if (isFunction(initializer)) {
returnedValue = initializer.call(instance, options, { instance, stamp: Stamp, args });
instance = returnedValue === undefined ? instance : returnedValue;
}
}
return instance;
};
}
/**
* Mutates the dstDescriptor by merging the srcComposable data into it.
* @param {Descriptor} dstDescriptor The descriptor object to merge into.
* @param {Composable} [srcComposable] The composable
* (either descriptor or Stamp) to merge data form.
* @returns {Descriptor} Returns the dstDescriptor argument.
*/
function mergeComposable(dstDescriptor, srcComposable) {
function mergeAssign(propName, action) {
if (!isObject(srcComposable[propName])) {
return;
}
if (!isObject(dstDescriptor[propName])) {
dstDescriptor[propName] = {};
}
action(dstDescriptor[propName], srcComposable[propName]);
}
function concatAssignFunctions(propName) {
const funcs = extractUniqueFunctions(dstDescriptor[propName], srcComposable[propName]);
if (funcs) dstDescriptor[propName] = funcs;
}
srcComposable = srcComposable?.compose || srcComposable;
if (isObject(srcComposable)) {
mergeAssign("methods", assignOne);
mergeAssign("properties", assignOne);
mergeAssign("deepProperties", mergeOne);
mergeAssign("propertyDescriptors", assignOne);
mergeAssign("staticProperties", assignOne);
mergeAssign("staticDeepProperties", mergeOne);
mergeAssign("staticPropertyDescriptors", assignOne);
mergeAssign("configuration", assignOne);
mergeAssign("deepConfiguration", mergeOne);
concatAssignFunctions("initializers");
concatAssignFunctions("composers");
}
return dstDescriptor;
}
/**
* Given the list of composables (Stamp descriptors and stamps) returns
* a new Stamp (composable factory function).
* @typedef {Function} Compose
* @param {...Composable} args The list of composables (aka plain objects and/or other stamps)
* @returns {Stamp} A new Stamp (aka composable factory function)
*/
function compose(...args) {
// "Composable" is both Descriptor and Stamp.
// The "this" context must be the first in the list.
const composables = [this, ...args].filter(isObject);
let stamp = createEmptyStamp();
const descriptor = composables.reduce(mergeComposable, {});
mergeOne(stamp, descriptor.staticDeepProperties);
assignOne(stamp, descriptor.staticProperties);
if (descriptor.staticPropertyDescriptors) Object.defineProperties(stamp, descriptor.staticPropertyDescriptors);
const c = isFunction(stamp.compose) ? stamp.compose : compose; // either use the Infected Compose or the standard one.
stamp.compose = function (...args) {
return c(this, ...args);
};
assignOne(stamp.compose, descriptor);
const composers = descriptor.composers;
if (Array.isArray(composers)) {
for (const composer of composers) {
const composerResult = composer({ stamp: stamp, composables });
stamp = isStamp(composerResult) ? composerResult : stamp;
}
}
return stamp;
}
/**
* The Stamp Descriptor
* @typedef {Function|Object} Descriptor
* @returns {Stamp} A new Stamp based on this Stamp
* @property {Object} [methods] Methods or other data used as object instances' prototype
* @property {Array<Function>} [initializers] List of initializers called for each object instance
* @property {Object} [properties] Shallow assigned properties of object instances
* @property {Object} [deepProperties] Deeply merged properties of object instances
* @property {Object} [staticProperties] Shallow assigned properties of Stamps
* @property {Object} [staticDeepProperties] Deeply merged properties of Stamps
* @property {Object} [configuration] Shallow assigned properties of Stamp arbitrary metadata
* @property {Object} [deepConfiguration] Deeply merged properties of Stamp arbitrary metadata
* @property {Object} [propertyDescriptors] ES5 Property Descriptors applied to object instances
* @property {Object} [staticPropertyDescriptors] ES5 Property Descriptors applied to Stamps
*/
/**
* The Stamp factory function
* @typedef {Function} Stamp
* @returns {*} Instantiated object
* @property {Descriptor} compose - The Stamp descriptor and composition function
*/
/**
* A composable object - Stamp or descriptor
* @typedef {Stamp|Descriptor} Composable
*/
/////////////
///////////// NOTE! Everything above is the compose(). The below is the stampit(). /////////////
/////////////
/**
* Converts stampit extended descriptor to a standard one.
* @param {Object} descr
* methods
* properties
* props
* initializers
* init
* deepProperties
* deepProps
* propertyDescriptors
* staticProperties
* statics
* staticDeepProperties
* deepStatics
* staticPropertyDescriptors
* configuration
* conf
* deepConfiguration
* deepConf
* composers
* @returns {Descriptor} Standardised descriptor
*/
function standardiseDescriptor(descr) {
// Avoid processing non-objects. Also, do not process stamps because they are already standard.
if (!isObject(descr) || isStamp(descr)) return descr;
const out = {};
out.methods = descr.methods || undefined;
const p1 = descr.properties;
const p2 = descr.props;
out.properties = isObject(p1 || p2) ? assign({}, p2, p1) : undefined;
out.initializers = extractUniqueFunctions(descr.init, descr.initializers);
out.composers = extractUniqueFunctions(descr.composers);
const dp1 = descr.deepProperties;
const dp2 = descr.deepProps;
out.deepProperties = isObject(dp1 || dp2) ? merge({}, dp2, dp1) : undefined;
out.propertyDescriptors = descr.propertyDescriptors;
const sp1 = descr.staticProperties;
const sp2 = descr.statics;
out.staticProperties = isObject(sp1 || sp2) ? assign({}, sp2, sp1) : undefined;
const sdp1 = descr.staticDeepProperties;
const sdp2 = descr.deepStatics;
out.staticDeepProperties = isObject(sdp1 || sdp2) ? merge({}, sdp2, sdp1) : undefined;
const spd1 = descr.staticPropertyDescriptors;
const spd2 = descr.name && { name: { value: descr.name } };
out.staticPropertyDescriptors = isObject(spd2 || spd1) ? assign({}, spd1, spd2) : undefined;
const c1 = descr.configuration;
const c2 = descr.conf;
out.configuration = isObject(c1 || c2) ? assign({}, c2, c1) : undefined;
const dc1 = descr.deepConfiguration;
const dc2 = descr.deepConf;
out.deepConfiguration = isObject(dc1 || dc2) ? merge({}, dc2, dc1) : undefined;
return out;
}
const staticUtils = {
methods(...args) {
return this.compose({ methods: assign({}, ...args) });
},
properties(...args) {
return this.compose({ properties: assign({}, ...args) });
},
initializers(...args) {
return this.compose({ initializers: extractUniqueFunctions(...args) });
},
composers(...args) {
return this.compose({ composers: extractUniqueFunctions(...args) });
},
deepProperties(...args) {
return this.compose({ deepProperties: merge({}, ...args) });
},
staticProperties(...args) {
return this.compose({ staticProperties: assign({}, ...args) });
},
staticDeepProperties(...args) {
return this.compose({ staticDeepProperties: merge({}, ...args) });
},
configuration(...args) {
return this.compose({ configuration: assign({}, ...args) });
},
deepConfiguration(...args) {
return this.compose({ deepConfiguration: merge({}, ...args) });
},
propertyDescriptors(...args) {
return this.compose({ propertyDescriptors: assign({}, ...args) });
},
staticPropertyDescriptors(...args) {
return this.compose({ staticPropertyDescriptors: assign({}, ...args) });
},
create(...args) {
return this(...args);
},
compose: stampit, // infecting!
};
staticUtils.props = staticUtils.properties;
staticUtils.init = staticUtils.initializers;
staticUtils.deepProps = staticUtils.deepProperties;
staticUtils.statics = staticUtils.staticProperties;
staticUtils.deepStatics = staticUtils.staticDeepProperties;
staticUtils.conf = staticUtils.configuration;
staticUtils.deepConf = staticUtils.deepConfiguration;
/**
* Create and return a Stamp.
* @param {...Composable} args The list of composables (aka plain objects and/or other stamps)
* @return {Stamp} The stampit-flavoured Stamp
*/
export default function stampit(...args) {
return compose(this, { staticProperties: staticUtils }, ...args.map(standardiseDescriptor));
}
export { stampit as "module.exports" };