UNPKG

@forestadmin/context

Version:

Minimal context management helper for applications and libraries.

418 lines (361 loc) 13.6 kB
const fs = require('fs'); const { sep, relative, join } = require('path'); const Context = require('./context'); const METADATA_HOOK = 'metadata-hook'; module.exports = class Plan { constructor(_entries = [], verbose = false) { this._entries = _entries; this._stepsWalk = []; this._verbose = verbose; } static newPlan(...args) { return new Plan(...args); } static makeWriteFilesystem(...basePath) { return (entries) => { entries.forEach(({ path: entryPath, name: entryName, type, requires }) => { if (type === 'step') { const folder = join(...basePath, ...entryPath.split('/')); fs.mkdirSync(folder, { recursive: true }); } else { const filename = `${entryName}.js`; const filepath = join(...basePath, ...entryPath.split('/'), filename); const requirePaths = requires .map(({ path, name }) => `${join(relative(entryPath, path), name)}`) .map((path) => (path.startsWith('.') ? path : `.${sep}${path}`)); const requiresList = requirePaths.map((require) => `require('${require}');`); const fileContent = `${requiresList.join('\n')}\n\nmodule.exports = 'hello!';\n`; fs.writeFileSync(filepath, fileContent); } }); }; } static makeDotWrite(...basePath) { return (entries) => { const getEntriesAsJson = () => { const root = []; const getStepContent = (pathString) => { if (pathString.length === 0) return root; let current = root; const pathArray = pathString.split('/'); pathArray.forEach((stepName) => { current = current.find(({ name }) => name === stepName).content; }); return current; }; entries.forEach(({ path, name, type, requires, options }) => { if (type === 'step') { const step = { type, name, options, content: [] }; const temp = path.split('/'); temp.pop(); // pop because we want the parent. path = temp.join('/'); getStepContent(path).push(step); } else { getStepContent(path).push({ type, name, options, requires }); } }); return root; }; const json = getEntriesAsJson(); const dot = []; dot.push('digraph G {'); dot.push(' rankdir=LR;'); let clusterCount = 0; const indent = (deep) => ' '.repeat(2 * deep); const links = []; const appendNode = (nodes, output, deep = 1) => { nodes.forEach((node) => { if (node.type === 'step') { output.push(`${indent(deep)}subgraph cluster_${clusterCount += 1} {`); deep += 1; output.push(`${indent(deep)}label = "${node.name}";`); appendNode(node.content, output, deep); deep -= 1; output.push(`${indent(deep)}}`); } else { output.push(`${indent(deep)}${node.name};`); } links.push(node); }); }; appendNode(json, dot); links.forEach((node) => { if (node.requires && node.requires.length) { node.requires.forEach((requisite) => dot.push(`${indent(1)}${node.name} -> ${requisite && requisite.name};`)); } }); dot.push('}'); const text = dot.join('\n'); fs.mkdirSync(join(...(basePath.slice(0, basePath.length - 1))), { recursive: true }); fs.writeFileSync(join(...basePath), text); }; } /** * This is useful in legacy code only. * It's to keep a context in a singleton (retrieved via inject()). * A context in a singleton is usefull to be used in files that are not in the context. * @param item * @param {boolean} [verbose] */ static init(item, verbose) { Plan.execute(item, Plan._context = new Context(), verbose); } static inject() { if (!Plan._context) throw new Error('Context not initiated'); return Plan._context.get(); } static execute(plan, context = new Context(), verbose = false) { if (!plan) throw new Error('missing item'); if (typeof plan === 'object' && !(plan instanceof Plan) && !Array.isArray(plan)) { context._bag = plan; return context.get(); } Plan ._mergeItem(plan, Plan.newPlan(undefined, verbose)) ._getEntries() .forEach((entry) => Plan.applyEntry(entry, context)); context.seal(); return context.get(); } static _mergeItem(item, plan) { if (!item) throw new Error('missing item'); const itemIsAnArray = Array.isArray(item); const itemIsAFunction = typeof item === 'function'; const itemIsAPlan = item instanceof Plan; const itemIsInvalid = !itemIsAFunction && !itemIsAnArray && !itemIsAPlan; if (itemIsAnArray) { item.forEach((subitem) => { plan = Plan._mergeItem(subitem, plan); }); } else if (itemIsAFunction) { plan = item(plan); if (!plan) throw new Error('a plan function should return a plan'); } else if (itemIsAPlan) { plan = Plan.newPlan([...plan._getEntries(), ...plan._prefixPaths(item._getEntries())]); } else if (itemIsInvalid) { throw new Error(`Invalid plan: received '${typeof item}' instead of 'Plan', 'function' or 'Array'`); } return plan; } static applyEntry(entry, context) { try { Plan._applyEntry(entry, context); } catch (error) { error.stack = `${error.stack}\nProblem origin - ${entry.stack || 'verbose-not-activated'}`; throw error; } } static _applyEntry(entry, context) { const { path, type, name, value, options, } = entry; if (context.isEntryIgnorable(entry)) return; switch (type) { case 'replacement': context.addReplacement(path, name, value, options); break; case 'value': context.addValue(path, name, value, options); break; case 'alias': context.addAlias(path, name, value, options); break; case 'number': context.addNumber(path, name, value, options); break; case 'rawValue': context.addRawValue(path, name, value, options); break; case 'instance': context.addInstance(path, name, value, options); break; case 'function': context.addFunction(path, name, value, options); break; case 'class': context.addUsingClass(path, name, value, options); break; case 'function*': context.addUsingFunction(path, name, value, options); break; case 'function**': context.addUsingFunctionStack(path, name, value, options); break; case 'module': context.addModule(path, name, value, options); break; case 'work': context.with(path, value.name, value.work, options); break; case 'step-in': context.openStep(path, value, options); break; case 'step-out': context.closeStep(path, value, options); break; case METADATA_HOOK: value(context.getMetadata()); break; default: throw new Error(`invalid entry type ${type} ${path} ${name}`); } } _prefixPaths(entries) { if (this._stepsWalk.length === 0) return entries; const prefix = this._stepsWalk.join('/'); return entries.map(({ path, ...rest }) => ({ path: `${prefix}${path.length > 0 ? '/' : ''}${path}`, ...rest })); } _addEntry(name, type, value, options) { if (process.env.NODE_ENV !== 'test' && name === 'assertPresent') throw new Error('reserved keyword "assertPresent"'); const path = this._stepsWalk.join('/'); const entry = { path, name, type, value }; if (options) entry.options = options; if (this._verbose) entry.stack = new Error().stack; this._entries.push(entry); } _getPathAndName(relativePath) { const absoluteSteps = [...this._stepsWalk, ...relativePath.split('/')]; const name = absoluteSteps.pop(); const path = absoluteSteps.join('/'); return { path, name }; } _getAbsolutePath(relativePath) { return [...this._stepsWalk, ...relativePath.split('/')].join('/'); } replace(relativePath, value, options) { const valueReplacedPlan = this._replaceValue(relativePath, value, options); if (valueReplacedPlan) return valueReplacedPlan; const stepReplacedPlan = this._replaceStep(relativePath, value, options); if (stepReplacedPlan) return stepReplacedPlan; throw new Error(`Invalid replace operation: relativePath not found '${relativePath}'`); } _replaceValue(relativePath, value, options) { const { path, name } = this._getPathAndName(relativePath); const replacedIndex = this._entries.findIndex( ({ path: entryPath, name: entryName }) => path === entryPath && name === entryName, ); if (replacedIndex === -1) return null; const replaced = this._entries[replacedIndex]; const replacingEntry = { path, name, type: 'replacement', value, options, replaced, }; const newEntries = this._entries.slice(); newEntries.splice(replacedIndex, 1, replacingEntry); return new Plan(newEntries); } _replaceStep(relativePath, valueObject, options) { const absolutePath = this._getAbsolutePath(relativePath); const replacedSteps = this._entries.filter( ({ path: entryPath }) => entryPath.startsWith(absolutePath), ); if (replacedSteps.length === 0) return null; const replacedIndex = this._entries.indexOf(replacedSteps[0]); const deleteCount = replacedSteps.length; const replacingEntries = Object .entries(valueObject) .map(([key, value]) => ({ path: absolutePath, name: key, type: 'replacement', value, options, })); replacingEntries[0].replaced = replacedSteps; const newEntries = this._entries.slice(); newEntries.splice(replacedIndex, deleteCount, ...replacingEntries); return new Plan(newEntries); } addPackage(name, item, options) { if (!item) throw new Error('Using addPackage: missing package definition'); return this.addStep(name, item, options); } addAlias(name, aliasOf, options) { if (!name) throw new Error(`name is falsy: ${name}`); if (!aliasOf) throw new Error(`alias is falsy: ${name}`); this._addEntry(name, 'alias', aliasOf, options); return this; } addStep(name, item, options) { this._stepsWalk.push(name); this._addEntry(Symbol('step-in'), 'step-in', name, options); const plan = Plan._mergeItem(item, this); this._addEntry(Symbol('step-out'), 'step-out', name, options); this._stepsWalk.pop(); return plan; } addValue(name, value, options) { if (value === undefined) throw new Error(`Value is undefined: ${name}`); this._addEntry(name, 'value', value, options); return this; } addNumber(name, value, options) { this._addEntry(name, 'number', value, options); return this; } _addRawValue(name, rawValue, options) { if (rawValue === undefined) throw new Error(`Raw value is undefined: ${name}`); this._addEntry(name, 'rawValue', rawValue, options); return this; } addInstance(name, instance, options) { if (instance === undefined) throw new Error(`Specified instance is undefined: ${name}`); this._addEntry(name, 'instance', instance, options); return this; } addFunction(name, func, options) { if (func === undefined) throw new Error(`Specified function is undefined: ${name}`); this._addEntry(name, 'function', func, options); return this; } addUsingClass(name, Class, options) { if (Class === undefined) throw new Error(`Specified class is undefined: ${name}`); this._addEntry(name, 'class', Class, options); return this; } /** * @deprecated Use addUsingClass instead. */ addClass(Class, options) { if (Class === undefined) throw new Error('Specified class is undefined'); const name = Plan._getInstanceName(Class, options); this._addEntry(name, 'class', Class, options); return this; } /** * @deprecated Use addUsingClass instead addClass. */ static _getInstanceName(Class, { name } = {}) { if (name) return name; const className = Class.name; return className.charAt(0).toLowerCase() + className.slice(1); } addUsingFunction(name, factoryFunction, options) { if (factoryFunction === undefined) throw new Error(`Specified factory function is undefined: ${name}`); this._addEntry(name, 'function*', factoryFunction, options); return this; } addUsingFunctionStack(name, factoryFunctionList, options) { if (!Array.isArray(factoryFunctionList)) throw new Error(`Invalid parameter must be array: factoryFunctionList: ${name}`); this._addEntry(name, 'function**', factoryFunctionList, options); return this; } addModule(name, module, options) { if (module === undefined) throw new Error(`Missing module: ${name}`); this._addEntry(name, 'module', module, options); return this; } addAllKeysFrom(object, options) { if (object === undefined) throw new Error('Missing object'); Object .entries(object) .forEach(([name, value]) => this._addRawValue(name, value, options)); return this; } with(name, work, options) { this._addEntry(Symbol('work'), 'work', { name, work }, options); return this; } _getEntries() { return this._entries; } addMetadataHook(hook) { this._addEntry(Symbol(METADATA_HOOK), METADATA_HOOK, hook); return this; } };