diff --git a/packages/core/src/pattern.mjs b/packages/core/src/pattern.mjs index f9abd74be97..d593c360304 100644 --- a/packages/core/src/pattern.mjs +++ b/packages/core/src/pattern.mjs @@ -11,9 +11,9 @@ import { Store } from './store.mjs' import { Hooks } from './hooks.mjs' import { version } from '../data.mjs' import { __loadPatternDefaults } from './config.mjs' +import { PatternConfig, getPluginName } from './patternConfig.mjs' import cloneDeep from 'lodash.clonedeep' -const DISTANCE_DEBUG = false ////////////////////////////////////////////// // CONSTRUCTOR // ////////////////////////////////////////////// @@ -25,7 +25,7 @@ const DISTANCE_DEBUG = false * @param {object} config - The Design config * @return {object} this - The Pattern instance */ -export function Pattern(designConfig) { +export function Pattern(designConfig = {}) { // Non-enumerable properties __addNonEnumProp(this, 'plugins', {}) __addNonEnumProp(this, 'width', 0) @@ -39,19 +39,9 @@ export function Pattern(designConfig) { __addNonEnumProp(this, 'Attributes', Attributes) __addNonEnumProp(this, 'macros', {}) __addNonEnumProp(this, '__initialized', false) - __addNonEnumProp(this, '__designParts', {}) - __addNonEnumProp(this, '__inject', {}) - __addNonEnumProp(this, '__dependencies', {}) - __addNonEnumProp(this, '__resolvedDependencies', {}) - __addNonEnumProp(this, '__resolvedParts', []) + __addNonEnumProp(this, 'config.parts', {}) + __addNonEnumProp(this, 'config.resolvedDependencies', {}) __addNonEnumProp(this, '__storeMethods', new Set()) - __addNonEnumProp(this, '__mutated', { - optionDistance: {}, - partDistance: {}, - partHide: {}, - partHideAll: {}, - }) - __addNonEnumProp(this, '__draftOrder', []) __addNonEnumProp(this, '__hide', {}) // Enumerable properties @@ -59,6 +49,7 @@ export function Pattern(designConfig) { this.config = {} // Will hold the resolved pattern after calling __init() this.store = new Store() // Pattern-wide store this.setStores = [] // Per-set stores + __addNonEnumProp(this, '__configResolver', new PatternConfig(this)) // handles config resolution during __init() as well as runtime part adding return this } @@ -74,16 +65,11 @@ export function Pattern(designConfig) { * @return {object} this - The Pattern instance */ Pattern.prototype.addPart = function (part, resolveImmediately = false) { - if (typeof part?.draft === 'function') { - if (part.name) { - this.designConfig.parts.push(part) - if (resolveImmediately) { - this.store.log.debug(`Perfoming runtime resolution of new part ${part.name}`) - this.__resolvePart([part]) - } else this.__initialized = false - } else this.store.log.error(`Part must have a name`) - } else this.store.log.error(`Part must have a draft() method`) - + if (this.__configResolver.validatePart(part) && this.designConfig.parts.indexOf(part) === -1) { + this.designConfig.parts.push(part) + if (resolveImmediately) this.__configResolver.addPart(part) + else this.__initialized = false + } return this } @@ -132,15 +118,15 @@ Pattern.prototype.createPartForSet = function (partName, set = 0) { this.parts[set][partName] = this.__createPartWithContext(partName, set) // Handle inject/inheritance - if (typeof this.__inject[partName] === 'string') { + if (typeof this.config.inject[partName] === 'string') { this.setStores[set].log.debug( - `Creating part \`${partName}\` from part \`${this.__inject[partName]}\`` + `Creating part \`${partName}\` from part \`${this.config.inject[partName]}\`` ) try { - this.parts[set][partName].__inject(this.parts[set][this.__inject[partName]]) + this.parts[set][partName].__inject(this.parts[set][this.config.inject[partName]]) } catch (err) { this.setStores[set].log.error([ - `Could not inject part \`${this.__inject[partName]}\` into part \`${partName}\``, + `Could not inject part \`${this.config.inject[partName]}\` into part \`${partName}\``, err, ]) } @@ -161,11 +147,11 @@ Pattern.prototype.createPartForSet = function (partName, set = 0) { } Pattern.prototype.draftPartForSet = function (partName, set) { - if (typeof this.__designParts?.[partName]?.draft === 'function') { + if (typeof this.config.parts?.[partName]?.draft === 'function') { this.activePart = partName try { this.__runHooks('prePartDraft') - const result = this.__designParts[partName].draft(this.parts[set][partName].shorthand()) + const result = this.config.parts[partName].draft(this.parts[set][partName].shorthand()) this.__runHooks('postPartDraft') if (typeof result === 'undefined') { this.setStores[set].log.error( @@ -361,209 +347,6 @@ Pattern.prototype.use = function (plugin, data) { // PRIVATE METHODS // ////////////////////////////////////////////// -/** - * Adds a part as a simple dependency - * - * @private - * @param {string} name - The name of the dependency - * @param {object} part - The part configuration - * @param {object} dep - The dependency configuration - * @return {object} this - The Pattern instance - */ -Pattern.prototype.__addDependency = function (dependencyList, part, dep) { - this[dependencyList][part.name] = this[dependencyList][part.name] || [] - if (dependencyList == '__resolvedDependencies' && DISTANCE_DEBUG) - this.store.log.debug(`add ${dep.name} to ${part.name} dependencyResolution`) - if (this[dependencyList][part.name].indexOf(dep.name) === -1) - this[dependencyList][part.name].push(dep.name) -} - -/** - * Resolves/Adds a part's design configuration to the pattern config - * - * @private - * @param {Part} part - The part of which to resolve the config - * @param {onject} config - The global config - * @param {Store} store - The store, used for logging - * @return {object} config - The mutated global config - */ -Pattern.prototype.__addPartConfig = function (part) { - if (this.__resolvedParts.includes(part.name)) return this - - // Add parts, using set to keep them unique in the array - this.designConfig.parts = [...new Set(this.designConfig.parts).add(part)] - - return this.__addPartOptions(part) - .__addPartMeasurements(part) - .__addPartOptionalMeasurements(part) - .__addPartPlugins(part) -} - -/** - * Resolves/Adds a part's configured measurements to the global config - * - * @private - * @param {Part} part - The part of which to resolve the config - * @param {array} list - The list of resolved measurements - * @return {Pattern} this - The Pattern instance - */ -Pattern.prototype.__addPartMeasurements = function (part, list = false) { - if (!this.config.measurements) this.config.measurements = [] - if (!list) list = this.config.measurements - if (part.measurements) { - for (const m of part.measurements) { - if (list.indexOf(m) === -1) { - list.push(m) - this.store.log.debug(`🟠 __${m}__ measurement is required in \`${part.name}\``) - } - } - } - - // Weed out duplicates - this.config.measurements = [...new Set(list)] - - return this -} - -/** - * Resolves/Adds a part's configured optional measurements to the global config - * - * @private - * @param {Part} part - The part of which to resolve the config - * @param {array} list - The list of resolved optional measurements - * @return {Pattern} this - The Pattern instance - */ -Pattern.prototype.__addPartOptionalMeasurements = function (part, list = false) { - if (!this.config.optionalMeasurements) this.config.optionalMeasurements = [] - if (!list) list = this.config.optionalMeasurements - if (part.optionalMeasurements) { - for (const m of part.optionalMeasurements) { - // Don't add it's a required measurement for another part - if (this.config.measurements.indexOf(m) === -1) { - if (list.indexOf(m) === -1) { - list.push(m) - this.store.log.debug(`🟡 __${m}__ measurement is optional in \`${part.name}\``) - } - } - } - } - - // Weed out duplicates - if (list.length > 0) this.config.optionalMeasurements = [...new Set(list)] - - return this -} - -/** - * Resolves/Adds a part's configured options to the global config - * - * @private - * @param {Part} part - The part of which to resolve the config - * @return {Pattern} this - The Pattern instance - */ -Pattern.prototype.__addPartOptions = function (part) { - if (!this.config.options) this.config.options = {} - if (part.options) { - const partDistance = this.__mutated.partDistance?.[part.name] || 0 - for (const optionName in part.options) { - const optionDistance = this.__mutated.optionDistance[optionName] - if (!optionDistance) { - this.__mutated.optionDistance[optionName] = partDistance - // Keep design parts immutable in the pattern or risk subtle bugs - this.config.options[optionName] = Object.freeze(part.options[optionName]) - this.store.log.debug(`🔵 __${optionName}__ option loaded from part \`${part.name}\``) - } else { - if (DISTANCE_DEBUG) - this.store.log.debug( - `optionDistance for __${optionName}__ is __${optionDistance}__ and partDistance for \`${part.name}\` is __${partDistance}__` - ) - if (optionDistance > partDistance) { - this.config.options[optionName] = part.options[optionName] - this.__mutated.optionDistance[optionName] = partDistance - this.store.log.debug(`🟣 __${optionName}__ option overwritten by \`${part.name}\``) - } - } - } - } - - return this -} - -function getPluginName(plugin) { - if (Array.isArray(plugin)) { - if (plugin[0].name) return plugin[0].name - if (plugin[0].plugin.name) return plugin[0].plugin.name - } else { - if (plugin.name) return plugin.name - if (plugin.plugin?.name) return plugin.plugin.name - } - - return false -} - -/** - * Resolves/Adds a part's configured plugins to the global config - * - * @private - * @param {Part} part - The part of which to resolve the config - * @return {Pattern} this - The Pattern instance - */ -Pattern.prototype.__addPartPlugins = function (part) { - if (!part.plugins) return this - if (!this.config.plugins) this.config.plugins = {} - const plugins = { ...this.config.plugins } - // Side-step immutability of the part object to ensure plugins is an array - let partPlugins = part.plugins - if (!Array.isArray(partPlugins)) partPlugins = [partPlugins] - // Go through list of part plugins - for (let plugin of partPlugins) { - const name = getPluginName(plugin) - this.store.log.debug( - plugin.plugin - ? `🔌 Resolved __${name}__ conditional plugin in \`${part.name}\`` - : `🔌 Resolved __${name}__ plugin in \`${part.name}\`` - ) - // Handle [plugin, data] scenario - if (Array.isArray(plugin)) { - const pluginObj = { ...plugin[0], data: plugin[1] } - plugin = pluginObj - } - if (!plugins[name]) { - // New plugin, so we load it - plugins[name] = plugin - this.store.log.info( - plugin.condition - ? `New plugin conditionally added: \`${name}\`` - : `New plugin added: \`${name}\`` - ) - } else { - // Existing plugin, takes some more work - if (plugin.plugin && plugin.condition) { - // Multiple instances of the same plugin with different conditions - // will all be added, so we need to change the name. - if (plugins[name]?.condition) { - plugins[name + '_'] = plugin - this.store.log.info( - `Plugin \`${name}\` was conditionally added again. Renaming to ${name}_.` - ) - } else - this.store.log.info( - `Plugin \`${name}\` was requested conditionally, but is already added explicitly. Not loading.` - ) - } - // swap from a conditional if needed - else if (plugins[name].condition) { - plugins[name] = plugin - this.store.log.info(`Plugin \`${name}\` was explicitly added. Changing from conditional.`) - } - } - } - - this.config.plugins = { ...plugins } - - return this -} - /** * Creates a store for a set (of settings) * @@ -620,7 +403,7 @@ Pattern.prototype.__createPartWithContext = function (name, set) { const part = new Part() part.name = name part.set = set - part.stack = this.__designParts[name]?.stack || name + part.stack = this.config.parts[name]?.stack || name part.context = { parts: this.parts[set], config: this.config, @@ -700,14 +483,11 @@ Pattern.prototype.__init = function () { * This methods does that, and resolves the design config + user settings */ this.__resolveParts() // Resolves parts - .__resolveDependencies() // Resolves dependencies - .__resolveDraftOrder() // Resolves draft order + .__resolveConfig() // Gets the config from the resolver .__loadPlugins() // Loads plugins .__loadConfigData() // Makes config data available in store - .__filterOptionalMeasurements() // Removes required m's from optional list - .__loadOptionDefaults() // Merges default options with user provided ones - this.store.log.info(`Pattern initialized. Draft order is: ${this.__draftOrder.join(', ')}`) + this.store.log.info(`Pattern initialized. Draft order is: ${this.config.draftOrder.join(', ')}`) this.__runHooks('postInit') this.__initialized = true @@ -726,10 +506,10 @@ Pattern.prototype.__isPartHidden = function (partName) { if (Array.isArray(this.settings[this.activeSet || 0].only)) { if (this.settings[this.activeSet || 0].only.includes(partName)) return false } - if (this.__designParts?.[partName]?.hide) return true - if (this.__designParts?.[partName]?.hideAll) return true - if (this.__mutated.partHide?.[partName]) return true - if (this.__mutated.partHideAll?.[partName]) return true + if (this.config.parts?.[partName]?.hide) return true + if (this.config.parts?.[partName]?.hideAll) return true + if (this.config.partHide?.[partName]) return true + if (this.config.partHideAll?.[partName]) return true if (this.parts?.[this.activeSet]?.[partName]?.hidden) return true return false @@ -822,40 +602,6 @@ Pattern.prototype.__loadConfigData = function () { return this } -/** - * Merges defaults for options with user-provided options - * - * @private - * @return {Pattern} this - The Pattern instance - */ -Pattern.prototype.__loadOptionDefaults = function () { - if (!this.config.options) this.config.options = {} - if (Object.keys(this.config.options).length < 1) return this - for (const i in this.settings) { - for (const [name, option] of Object.entries(this.config.options)) { - // Don't overwrite user-provided settings.options - if (typeof this.settings[i].options[name] === 'undefined') { - if (typeof option === 'object') { - if (typeof option.pct !== 'undefined') this.settings[i].options[name] = option.pct / 100 - else if (typeof option.mm !== 'undefined') this.settings[i].options[name] = option.mm - else if (typeof option.deg !== 'undefined') this.settings[i].options[name] = option.deg - else if (typeof option.count !== 'undefined') - this.settings[i].options[name] = option.count - else if (typeof option.bool !== 'undefined') this.settings[i].options[name] = option.bool - else if (typeof option.dflt !== 'undefined') this.settings[i].options[name] = option.dflt - else { - let err = 'Unknown option type: ' + JSON.stringify(option) - this.store.log.error(err) - throw new Error(err) - } - } else this.settings[i].options[name] = option - } - } - } - - return this -} - /** * Loads a plugin * @@ -1049,8 +795,8 @@ Pattern.prototype.__needs = function (partName, set = 0) { // Walk the only parts, checking each one for a match in its dependencies for (const part of only) { if (part === partName) return true - if (this.__resolvedDependencies[part]) { - for (const dependency of this.__resolvedDependencies[part]) { + if (this.config.resolvedDependencies[part]) { + for (const dependency of this.config.resolvedDependencies[part]) { if (dependency === partName) return true } } @@ -1183,117 +929,11 @@ Pattern.prototype.__pack = function () { return this } -/** - * Resolves the draft order based on the configuation - * - * @private - * @param {object} graph - The object of resolved dependencies, used to call itself recursively - * @return {Pattern} this - The Pattern instance - */ -Pattern.prototype.__resolveDraftOrder = function (graph = this.__resolvedDependencies) { - const sorted = Object.keys(this.__designParts).sort( - (p1, p2) => this.__mutated.partDistance[p2] - this.__mutated.partDistance[p1] - ) - this.__draftOrder = sorted - this.config.draftOrder = sorted - +Pattern.prototype.__resolveConfig = function () { + this.config = this.__configResolver.asConfig() return this } -Pattern.prototype.__resolvePartDependencyChain = function (depChain, dependency, depType) { - const part = depChain[0] - - this.__designParts[dependency.name] = Object.freeze(dependency) - this.__addDependency('__dependencies', part, dependency) - - depChain.forEach((c) => this.__addDependency('__resolvedDependencies', c, dependency)) - - switch (depType) { - case 'from': - this.__setFromHide(part, part.name, dependency.name) - this.__inject[part.name] = dependency.name - break - case 'after': - this.__setAfterHide(part, part.name, dependency.name) - } -} - -Pattern.prototype.__resolveMutatedPartDistance = function (partName) { - const proposed_dependent_part_distance = this.__mutated.partDistance[partName] + 1 - let didChange = false - if (!this.__dependencies[partName]) return false - this.__dependencies[partName].forEach((dependency) => { - if ( - typeof this.__mutated.partDistance[dependency] === 'undefined' || - this.__mutated.partDistance[dependency] < proposed_dependent_part_distance - ) { - didChange = true - this.__mutated.partDistance[dependency] = proposed_dependent_part_distance - this.__resolveMutatedPartDistance(dependency) - } - if (DISTANCE_DEBUG) - this.store.log.debug( - `"${depType}:" partDistance for \`${dependency}\` is __${this.__mutated.partDistance[dependency]}__` - ) - }) - - return didChange -} - -const depTypes = ['from', 'after'] -Pattern.prototype.__resolvePartDependencies = function (depChain, distance) { - // Resolve part Dependencies. first from then after - const part = depChain[0] - this.__resolvedDependencies[part.name] = this.__resolvedDependencies[part.name] || [] - - depTypes.forEach((d) => { - if (part[d]) { - if (DISTANCE_DEBUG) this.store.log.debug(`Processing \`${part.name}\` "${d}:"`) - - const depsOfType = Array.isArray(part[d]) ? part[d] : [part[d]] - - depsOfType.forEach((dot) => { - let count = Object.keys(this.__designParts).length - // if any changes resulted from resolving this part mutation - this.__resolvePartDependencyChain(depChain, dot, d) - // if a new part was added, resolve the part - const newCount = Object.keys(this.__designParts).length - if (count < newCount) { - this.__resolvePart([dot, ...depChain], distance) - count = newCount - } - }) - } - }) - - this.__resolveMutatedPartDistance(part.name) -} - -Pattern.prototype.__resolvePart = function (depChain, distance = 0) { - const part = depChain[0] - if (distance === 0) { - this.__designParts[part.name] = Object.freeze(part) - } - distance++ - if (typeof this.__mutated.partDistance[part.name] === 'undefined') { - this.__mutated.partDistance[part.name] = distance - - if (DISTANCE_DEBUG) - this.store.log.debug( - `Base partDistance for \`${part.name}\` is __${this.__mutated.partDistance[part.name]}__` - ) - } - - // Hide when hideAll is set - if (part.hideAll) { - this.__mutated.partHide[part.name] = true - } - - this.__resolvePartDependencies(depChain, distance) - - // add the part's config - this.__addPartConfig(part) -} /** * Resolves parts and their dependencies * @@ -1302,35 +942,15 @@ Pattern.prototype.__resolvePart = function (depChain, distance = 0) { * @param {int} distance - Keeps track of how far the dependency is from the pattern * @return {Pattern} this - The Pattern instance */ -Pattern.prototype.__resolveParts = function (count = 0, distance = 0) { - for (const part of this.designConfig.parts) { - this.__resolvePart([part], distance) - } +Pattern.prototype.__resolveParts = function () { + this.designConfig.parts.forEach((p) => this.__configResolver.addPart(p)) // Print final part distances. - for (const part of this.designConfig.parts) { - let qualifier = DISTANCE_DEBUG ? 'final' : '' - this.store.log.debug( - `⚪️ \`${part.name}\` ${qualifier} options priority is __${ - this.__mutated.partDistance[part.name] - }__` - ) - } + this.__configResolver.logPartDistances() return this } -/** - * Resolves parts depdendencies into a flat array - * - * @private - * @return {Pattern} this - The Pattern instance - */ -Pattern.prototype.__resolveDependencies = function () { - this.config.resolvedDependencies = this.__resolvedDependencies - return this -} - /** * Runs subscriptions to a given lifecycle hook * @@ -1364,49 +984,6 @@ Pattern.prototype.__setBase = function () { } } -/** - * Sets visibility of a dependency based on its config - * - * @private - * @param {Part} part - The part of which this is a dependency - * @param {string} name - The name of the part - * @param {string} depName - The name of the dependency - * @param {int} set - The index of the set in the list of settings - * @return {Pattern} this - The Pattern instance - */ -Pattern.prototype.__setFromHide = function (part, name, depName) { - if ( - part.hideDependencies || - part.hideAll || - this.__mutated.partHide[name] || - this.__mutated.partHideAll[name] - ) { - this.__mutated.partHide[depName] = true - this.__mutated.partHideAll[depName] = true - } - - return this -} - -/** - * Sets visibility of an 'after' dependency based on its config - * - * @private - * @param {Part} part - The part of which this is a dependency - * @param {string} name - The name of the part - * @param {string} depName - The name of the dependency - * @param {int} set - The index of the set in the list of settings - * @return {Pattern} this - The Pattern instance - */ -Pattern.prototype.__setAfterHide = function (part, name, depName) { - if (this.__mutated.partHide[name] || this.__mutated.partHideAll[name]) { - this.__mutated.partHide[depName] = true - this.__mutated.partHideAll[depName] = true - } - - return this -} - /** * Returns the absolute value of a snapped percentage option * @@ -1493,22 +1070,3 @@ Pattern.prototype.__wants = function (partName, set = 0) { return true } - -////////////////////////////////////////////// -// STATIC PRIVATE FUNCTIONS // -////////////////////////////////////////////// - -/** - * Merges dependencies into a flat list - * - * @private - * @param {array} dep - New dependencies - * @param {array} current - Current dependencies - * @return {array} deps - Merged dependencies - */ -function mergeDependencies(dep = [], current = []) { - // Current dependencies - const list = [].concat(current, dep) - - return [...new Set(list)] -} diff --git a/packages/core/src/patternConfig.mjs b/packages/core/src/patternConfig.mjs new file mode 100644 index 00000000000..a28abb1b432 --- /dev/null +++ b/packages/core/src/patternConfig.mjs @@ -0,0 +1,419 @@ +import { __addNonEnumProp } from './utils.mjs' + +export function getPluginName(plugin) { + if (Array.isArray(plugin)) { + if (plugin[0].name) return plugin[0].name + if (plugin[0].plugin.name) return plugin[0].plugin.name + } else { + if (plugin.name) return plugin.name + if (plugin.plugin?.name) return plugin.plugin.name + } + + return false +} + +export function PatternConfig(pattern) { + this.pattern = pattern + this.store = pattern.store + __addNonEnumProp(this, 'plugins', { ...(pattern.designConfig.plugins || {}) }) + __addNonEnumProp(this, 'options', { ...(pattern.designConfig.options || {}) }) + __addNonEnumProp(this, 'measurements', [...(pattern.designConfig.measurements || [])]) + __addNonEnumProp(this, 'optionalMeasurements', [ + ...(pattern.designConfig.optionalMeasurements || []), + ]) + __addNonEnumProp(this, 'inject', {}) + __addNonEnumProp(this, 'directDependencies', {}) + __addNonEnumProp(this, 'resolvedDependencies', {}) + __addNonEnumProp(this, 'parts', {}) + __addNonEnumProp(this, '__resolvedParts', {}) + __addNonEnumProp(this, '__mutated', { + optionDistance: {}, + partDistance: {}, + partHide: {}, + partHideAll: {}, + }) +} + +const DISTANCE_DEBUG = false + +PatternConfig.prototype.validatePart = function (part) { + if (typeof part?.draft !== 'function') { + this.store.log.error(`Part must have a draft() method`) + return false + } + + if (!part.name) { + this.store.log.error(`Part must have a name`) + return false + } + + return true +} +PatternConfig.prototype.addPart = function (part) { + if (this.validatePart(part)) this.__resolvePart([part]) + + return this +} + +PatternConfig.prototype.logPartDistances = function () { + for (const partName in this.parts) { + let qualifier = DISTANCE_DEBUG ? 'final' : '' + this.store.log.debug( + `⚪️ \`${partName}\` ${qualifier} options priority is __${this.__mutated.partDistance[partName]}__` + ) + } +} + +PatternConfig.prototype.asConfig = function () { + return { + parts: this.parts, + plugins: this.plugins, + measurements: this.measurements, + options: this.options, + optionalMeasurements: this.optionalMeasurements, + resolvedDependencies: this.resolvedDependencies, + directDependencies: this.directDependencies, + inject: this.inject, + draftOrder: this.__resolveDraftOrder(), + partHide: this.__mutated.partHide, + partHideAll: this.__mutated.partHideAll, + } +} + +PatternConfig.prototype.__resolvePart = function (depChain, distance = 0) { + const part = depChain[0] + if (distance === 0) { + this.parts[part.name] = Object.freeze(part) + } + distance++ + if (typeof this.__mutated.partDistance[part.name] === 'undefined') { + this.__mutated.partDistance[part.name] = distance + + if (DISTANCE_DEBUG) + this.store.log.debug( + `Base partDistance for \`${part.name}\` is __${this.__mutated.partDistance[part.name]}__` + ) + } + + // Hide when hideAll is set + if (part.hideAll) { + this.__mutated.partHide[part.name] = true + } + + this.__resolvePartDependencies(depChain, distance) + + // add the part's config + this.__addPartConfig(part) +} + +/** + * Resolves/Adds a part's design configuration to the pattern config + * + * @private + * @param {Part} part - The part of which to resolve the config + * @param {onject} config - The global config + * @param {Store} store - The store, used for logging + * @return {object} config - The mutated global config + */ +PatternConfig.prototype.__addPartConfig = function (part) { + if (this.__resolvedParts[part.name]) return this + + // Add parts, using set to keep them unique in the array + // this.designConfig.parts = [...new Set(this.designConfig.parts).add(part)] + + return this.__addPartOptions(part) + .__addPartMeasurements(part, true) + .__addPartMeasurements(part, false) + .__addPartPlugins(part) +} + +/** + * Resolves/Adds a part's configured options to the global config + * + * @private + * @param {Part} part - The part of which to resolve the config + * @return {Pattern} this - The Pattern instance + */ +PatternConfig.prototype.__addPartOptions = function (part) { + if (!part.options) return this + + const partDistance = this.__mutated.partDistance?.[part.name] || 0 + for (const optionName in part.options) { + const option = part.options[optionName] + const optionDistance = this.__mutated.optionDistance[optionName] + if (optionDistance && DISTANCE_DEBUG) + this.store.log.debug( + `optionDistance for __${optionName}__ is __${optionDistance}__ and partDistance for \`${part.name}\` is __${partDistance}__` + ) + if (!optionDistance || optionDistance > partDistance) { + this.__mutated.optionDistance[optionName] = partDistance + // Keep design parts immutable in the pattern or risk subtle bugs + this.options[optionName] = Object.freeze(option) + this.store.log.debug( + optionDistance + ? `🟣 __${optionName}__ option overwritten by \`${part.name}\`` + : `🔵 __${optionName}__ option loaded from part \`${part.name}\`` + ) + this.__loadOptionDefault(optionName, option) + } + } + + return this +} + +/** + * Resolves/Adds a part's configured measurements to the global config + * + * @private + * @param {Part} part - The part of which to resolve the config + * @param {array} list - The list of resolved measurements + * @return {Pattern} this - The Pattern instance + */ +PatternConfig.prototype.__addPartMeasurements = function (part, optional = false) { + const listType = optional ? 'optionalMeasurements' : 'measurements' + if (part[listType]) { + part[listType].forEach((m) => { + const isInReqList = this.measurements.indexOf(m) !== -1 + const optInd = this.optionalMeasurements.indexOf(m) + const isInOptList = optInd !== -1 + + if (isInReqList) return + if (optional && !isInOptList) this.optionalMeasurements.push(m) + if (!optional) { + this.measurements.push(m) + + if (isInOptList) this.optionalMeasurements.splice(optInd, 1) + } + + this.store.log.debug( + `🟠 __${m}__ measurement is ${optional ? 'optional' : 'required'} in \`${part.name}\`` + ) + }) + } + + return this +} + +/** + * Resolves/Adds a part's configured plugins to the global config + * + * @private + * @param {Part} part - The part of which to resolve the config + * @return {Pattern} this - The Pattern instance + */ +PatternConfig.prototype.__addPartPlugins = function (part) { + if (!part.plugins) return this + + const plugins = this.plugins + // Side-step immutability of the part object to ensure plugins is an array + let partPlugins = part.plugins + if (!Array.isArray(partPlugins)) partPlugins = [partPlugins] + // Go through list of part plugins + for (let plugin of partPlugins) { + const name = getPluginName(plugin) + this.store.log.debug( + plugin.plugin + ? `🔌 Resolved __${name}__ conditional plugin in \`${part.name}\`` + : `🔌 Resolved __${name}__ plugin in \`${part.name}\`` + ) + // Handle [plugin, data] scenario + if (Array.isArray(plugin)) { + const pluginObj = { ...plugin[0], data: plugin[1] } + plugin = pluginObj + } + if (!plugins[name]) { + // New plugin, so we load it + plugins[name] = plugin + this.store.log.info( + plugin.condition + ? `New plugin conditionally added: \`${name}\`` + : `New plugin added: \`${name}\`` + ) + } else { + // Existing plugin, takes some more work + if (plugin.plugin && plugin.condition) { + // Multiple instances of the same plugin with different conditions + // will all be added, so we need to change the name. + if (plugins[name]?.condition) { + plugins[name + '_'] = plugin + this.store.log.info( + `Plugin \`${name}\` was conditionally added again. Renaming to ${name}_.` + ) + } else + this.store.log.info( + `Plugin \`${name}\` was requested conditionally, but is already added explicitly. Not loading.` + ) + } + // swap from a conditional if needed + else if (plugins[name].condition) { + plugins[name] = plugin + this.store.log.info(`Plugin \`${name}\` was explicitly added. Changing from conditional.`) + } + } + } + + return this +} + +PatternConfig.prototype.__loadOptionDefault = function (optionName, option) { + this.pattern.settings.forEach((set) => { + if (typeof set.options[optionName] !== 'undefined') return + if (typeof option === 'object') { + if (typeof option.pct !== 'undefined') set.options[optionName] = option.pct / 100 + else if (typeof option.mm !== 'undefined') set.options[optionName] = option.mm + else if (typeof option.deg !== 'undefined') set.options[optionName] = option.deg + else if (typeof option.count !== 'undefined') set.options[optionName] = option.count + else if (typeof option.bool !== 'undefined') set.options[optionName] = option.bool + else if (typeof option.dflt !== 'undefined') set.options[optionName] = option.dflt + else { + let err = 'Unknown option type: ' + JSON.stringify(option) + this.store.log.error(err) + throw new Error(err) + } + } else set.options[optionName] = option + }) +} + +PatternConfig.prototype.__resolvePartDependencyChain = function (depChain, dependency, depType) { + const part = depChain[0] + + this.parts[dependency.name] = Object.freeze(dependency) + this.__addDependency('directDependencies', part, dependency) + + depChain.forEach((c) => this.__addDependency('resolvedDependencies', c, dependency)) + + switch (depType) { + case 'from': + this.__setFromHide(part, part.name, dependency.name) + this.inject[part.name] = dependency.name + break + case 'after': + this.__setAfterHide(part, part.name, dependency.name) + } +} + +PatternConfig.prototype.__resolveMutatedPartDistance = function (partName) { + const proposed_dependent_part_distance = this.__mutated.partDistance[partName] + 1 + let didChange = false + if (!this.directDependencies[partName]) return false + this.directDependencies[partName].forEach((dependency) => { + if ( + typeof this.__mutated.partDistance[dependency] === 'undefined' || + this.__mutated.partDistance[dependency] < proposed_dependent_part_distance + ) { + didChange = true + this.__mutated.partDistance[dependency] = proposed_dependent_part_distance + this.__resolveMutatedPartDistance(dependency) + } + if (DISTANCE_DEBUG) + this.store.log.debug( + `partDistance for \`${dependency}\` is __${this.__mutated.partDistance[dependency]}__` + ) + }) + + return didChange +} + +const depTypes = ['from', 'after'] +PatternConfig.prototype.__resolvePartDependencies = function (depChain, distance) { + // Resolve part Dependencies. first from then after + const part = depChain[0] + this.resolvedDependencies[part.name] = this.resolvedDependencies[part.name] || [] + + depTypes.forEach((d) => { + if (part[d]) { + if (DISTANCE_DEBUG) this.store.log.debug(`Processing \`${part.name}\` "${d}:"`) + + const depsOfType = Array.isArray(part[d]) ? part[d] : [part[d]] + + depsOfType.forEach((dot) => { + let count = Object.keys(this.parts).length + // if any changes resulted from resolving this part mutation + this.__resolvePartDependencyChain(depChain, dot, d) + // if a new part was added, resolve the part + const newCount = Object.keys(this.parts).length + if (count < newCount) { + this.__resolvePart([dot, ...depChain], distance) + count = newCount + } + }) + } + }) + + this.__resolveMutatedPartDistance(part.name) +} + +/** + * Adds a part as a simple dependency + * + * @private + * @param {string} name - The name of the dependency + * @param {object} part - The part configuration + * @param {object} dep - The dependency configuration + * @return {object} this - The Pattern instance + */ +PatternConfig.prototype.__addDependency = function (dependencyList, part, dep) { + this[dependencyList][part.name] = this[dependencyList][part.name] || [] + if (dependencyList == 'resolvedDependencies' && DISTANCE_DEBUG) + this.store.log.debug(`add ${dep.name} to ${part.name} dependencyResolution`) + if (this[dependencyList][part.name].indexOf(dep.name) === -1) + this[dependencyList][part.name].push(dep.name) +} + +/** + * Resolves the draft order based on the configuation + * + * @private + * @param {object} graph - The object of resolved dependencies, used to call itself recursively + * @return {Pattern} this - The Pattern instance + */ +PatternConfig.prototype.__resolveDraftOrder = function () { + this.__draftOrder = Object.keys(this.parts).sort( + (p1, p2) => this.__mutated.partDistance[p2] - this.__mutated.partDistance[p1] + ) + + return this.__draftOrder +} + +/** + * Sets visibility of a dependency based on its config + * + * @private + * @param {Part} part - The part of which this is a dependency + * @param {string} name - The name of the part + * @param {string} depName - The name of the dependency + * @param {int} set - The index of the set in the list of settings + * @return {Pattern} this - The Pattern instance + */ +PatternConfig.prototype.__setFromHide = function (part, name, depName) { + if ( + part.hideDependencies || + part.hideAll || + this.__mutated.partHide[name] || + this.__mutated.partHideAll[name] + ) { + this.__mutated.partHide[depName] = true + this.__mutated.partHideAll[depName] = true + } + + return this +} + +/** + * Sets visibility of an 'after' dependency based on its config + * + * @private + * @param {Part} part - The part of which this is a dependency + * @param {string} name - The name of the part + * @param {string} depName - The name of the dependency + * @param {int} set - The index of the set in the list of settings + * @return {Pattern} this - The Pattern instance + */ +PatternConfig.prototype.__setAfterHide = function (part, name, depName) { + if (this.__mutated.partHide[name] || this.__mutated.partHideAll[name]) { + this.__mutated.partHide[depName] = true + this.__mutated.partHideAll[depName] = true + } + + return this +} diff --git a/packages/core/tests/pattern-init.test.mjs b/packages/core/tests/pattern-init.test.mjs index 8f5c76621d7..036d91e89bd 100644 --- a/packages/core/tests/pattern-init.test.mjs +++ b/packages/core/tests/pattern-init.test.mjs @@ -32,12 +32,12 @@ describe('Pattern', () => { expect(typeof pattern.Snippet).to.equal('function') expect(typeof pattern.Attributes).to.equal('function') expect(typeof pattern.macros).to.equal('object') - expect(typeof pattern.__designParts).to.equal('object') - expect(typeof pattern.__inject).to.equal('object') - expect(typeof pattern.__dependencies).to.equal('object') - expect(typeof pattern.__resolvedDependencies).to.equal('object') - expect(typeof pattern.__hide).to.equal('object') - expect(Array.isArray(pattern.__draftOrder)).to.equal(true) + // expect(typeof pattern.__designParts).to.equal('object') + // expect(typeof pattern.config.inject).to.equal('object') + // expect(typeof pattern.config.directDependencies).to.equal('object') + // expect(typeof pattern.__resolvedDependencies).to.equal('object') + // expect(typeof pattern.__hide).to.equal('object') + // expect(Array.isArray(pattern.__draftOrder)).to.equal(true) expect(pattern.width).to.equal(0) expect(pattern.height).to.equal(0) expect(pattern.is).to.equal('') @@ -145,7 +145,7 @@ describe('Pattern', () => { }) it('Pattern.__init() should resolve parts', () => { - expect(pattern.designConfig.parts.length).to.equal(3) + expect(Object.keys(pattern.config.parts)).to.have.lengthOf(3) }) it('Pattern.__init() should resolve plugins', () => { @@ -324,14 +324,14 @@ describe('Pattern', () => { expect(pattern.config.options.optionR.list[1]).to.equal('green') expect(pattern.config.options.optionR.list[2]).to.equal('blue') // Dependencies - expect(pattern.__dependencies.partB).to.include('partA') - expect(pattern.__dependencies.partC).to.include('partB') - expect(pattern.__dependencies.partR).to.include('partC') - expect(pattern.__dependencies.partR).to.include('partA') + expect(pattern.config.directDependencies.partB).to.include('partA') + expect(pattern.config.directDependencies.partC).to.include('partB') + expect(pattern.config.directDependencies.partR).to.include('partC') + expect(pattern.config.directDependencies.partR).to.include('partA') // Inject - expect(pattern.__inject.partB).to.equal('partA') - expect(pattern.__inject.partC).to.equal('partB') - expect(pattern.__inject.partR).to.equal('partA') + expect(pattern.config.inject.partB).to.equal('partA') + expect(pattern.config.inject.partC).to.equal('partB') + expect(pattern.config.inject.partR).to.equal('partA') // Draft order expect(pattern.config.draftOrder[0]).to.equal('partA') expect(pattern.config.draftOrder[1]).to.equal('partB') @@ -472,12 +472,12 @@ describe('Pattern', () => { expect(pattern.config.options.optionD.list[1]).to.equal('green') expect(pattern.config.options.optionD.list[2]).to.equal('blue') // Dependencies - expect(pattern.__dependencies.partB[0]).to.equal('partA') - expect(pattern.__dependencies.partC[0]).to.equal('partB') - expect(pattern.__dependencies.partD[0]).to.equal('partC') + expect(pattern.config.directDependencies.partB[0]).to.equal('partA') + expect(pattern.config.directDependencies.partC[0]).to.equal('partB') + expect(pattern.config.directDependencies.partD[0]).to.equal('partC') // Inject - expect(pattern.__inject.partB).to.equal('partA') - expect(pattern.__inject.partC).to.equal('partB') + expect(pattern.config.inject.partB).to.equal('partA') + expect(pattern.config.inject.partC).to.equal('partB') // Draft order expect(pattern.config.draftOrder[0]).to.equal('partA') expect(pattern.config.draftOrder[1]).to.equal('partB') diff --git a/packages/core/tests/pattern-runtime-parts.test.mjs b/packages/core/tests/pattern-runtime-parts.test.mjs index a3a640e6721..dbe71799dbb 100644 --- a/packages/core/tests/pattern-runtime-parts.test.mjs +++ b/packages/core/tests/pattern-runtime-parts.test.mjs @@ -12,31 +12,31 @@ describe('Pattern', () => { const part2 = { name: 'test2', - from: part1, + after: part1, draft: ({ part }) => part, } const part3 = { name: 'test3', - after: part2, + from: part2, draft: ({ part }) => part, } describe('with resolveImmediately: true', () => { - it('Should add the part to the internal part object', () => { + it('Should add the part to parts object', () => { const design = new Design({ parts: [part1] }) const pattern = new design() pattern.__init() pattern.addPart(part2, true) - expect(pattern.__designParts.test2).to.equal(part2) + expect(pattern.config.parts.test2).to.equal(part2) }) it('Should resolve injected dependencies for the new part', () => { const design = new Design({ parts: [part1] }) const pattern = new design() pattern.__init() - pattern.addPart(part2, true) - expect(pattern.__inject.test2).to.equal('test') + pattern.addPart(part3, true) + expect(pattern.config.inject.test3).to.equal('test2') }) it('Should resolve all dependencies for the new part', () => { @@ -45,7 +45,7 @@ describe('Pattern', () => { pattern.__init() pattern.addPart(part3, true) expect(pattern.config.resolvedDependencies.test3).to.have.members(['test', 'test2']) - expect(pattern.__designParts.test2).to.equal(part2) + expect(pattern.config.parts.test2).to.equal(part2) }) it('Should add a the measurements for the new part', () => { @@ -79,7 +79,7 @@ describe('Pattern', () => { expect(pattern.config.plugins.testPlugin).to.equal(plugin) }) - it('Should add the options for the new part', () => { + it('Should resolve the options for the new part', () => { const design = new Design({ parts: [part1] }) const pattern = new design() pattern.__init() @@ -96,6 +96,61 @@ describe('Pattern', () => { pattern.addPart(part2, true) expect(pattern.config.options.opt1).to.equal(opt1) }) + + it('Should resolve the dependency options for the new part', () => { + const design = new Design({ parts: [part1] }) + const pattern = new design() + pattern.__init() + + const opt1 = { pct: 10, min: 0, max: 50 } + const part2 = { + name: 'test2', + options: { + opt1, + }, + draft: ({ part }) => part, + } + + const part3 = { + name: 'test3', + from: part2, + draft: ({ part }) => part, + } + + pattern.addPart(part3, true) + expect(pattern.config.options.opt1).to.equal(opt1) + }) + + it('Should resolve the overwritten options for the new part', () => { + const design = new Design({ parts: [part1] }) + const pattern = new design() + pattern.__init() + + const opt1 = { pct: 10, min: 0, max: 50 } + const part2 = { + name: 'test2', + options: { + opt1: { pct: 15, min: 10, max: 55 }, + }, + draft: ({ part }) => part, + } + + const part3 = { + name: 'test3', + from: part2, + options: { + opt1, + }, + draft: ({ part }) => part, + } + + pattern.addPart(part3, true) + expect(pattern.config.options.opt1).to.equal(opt1) + }) + }) + + describe('with resolveImmediately: false', () => { + it('does not create duplications in the configuration') }) }) }) diff --git a/packages/core/tests/snap.test.mjs b/packages/core/tests/snap.test.mjs index fa64a1f9f6b..4c51de52472 100644 --- a/packages/core/tests/snap.test.mjs +++ b/packages/core/tests/snap.test.mjs @@ -42,6 +42,7 @@ describe('Snapped options', () => { snap: [1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144], }, }, + draft: ({ part }) => part, } const design = new Design({ parts: [part] }) const patternA = new design({ options: { test: 0.13 }, measurements }).draft() @@ -67,6 +68,7 @@ describe('Snapped options', () => { }, }, }, + draft: ({ part }) => part, } const design = new Design({ parts: [part] }) const patternA = new design({ options: { test: 0.13 }, measurements, units: 'metric' }).draft() @@ -94,6 +96,7 @@ describe('Snapped options', () => { }, }, }, + draft: ({ part }) => part, } const design = new Design({ parts: [part] }) const patternA = new design({