UNPKG

yeoman-generator

Version:

Rails-inspired generator system that provides scaffolding for your apps

358 lines (357 loc) • 11.8 kB
import assert from 'node:assert'; import { cloneDeep, get, merge, set, defaults as setDefaults } from './object.js'; import sortKeys from 'sort-keys'; /** * Proxy handler for Storage */ const proxyHandler = { get(storage, property, _receiver) { return storage.get(property, { raw: true }); }, set(storage, property, value, _receiver) { if (typeof property === 'string') { storage.set(property, value); return true; } return false; }, deleteProperty(storage, property) { if (typeof property === 'string') { storage.delete(property); return true; } return false; }, ownKeys(storage) { return Reflect.ownKeys(storage._store); }, has(storage, prop) { return storage.get(prop, { raw: true }) !== undefined; }, getOwnPropertyDescriptor(storage, key) { return { get: () => this.get(storage, key, null), enumerable: true, configurable: true, }; }, }; /** * Storage instances handle a json file where Generator authors can store data. * * The `Generator` class instantiate the storage named `config` by default. * * @constructor * @param name The name of the new storage (this is a namespace) * @param fs A mem-fs editor instance * @param configPath The filepath used as a storage. * * @example * class extend Generator { * writing: function() { * this.config.set('coffeescript', false); * } * } */ class Storage { path; name; fs; indent; lodashPath; disableCache; /** * @deprecated Has no effect: the cache is dropped when the contents of the storage file change, whoever changes them. */ disableCacheByFile; sorted; existed; _cachedStore; /** * The contents of the file the cached store was read or written with. mem-fs-editor gives a file new contents on * every write, so other contents make the cache outdated, whoever wrote them. */ _cachedContents; transform; constructor(name, fs, configPath, options = {}) { let editor; let actualName; let actualConfigPath; let actualOptions = options; if (typeof name === 'string') { actualName = name; } else if (typeof name === 'object') { editor = name; } if (typeof fs === 'string') { actualConfigPath = fs; } else { editor = fs; } if (typeof configPath === 'string') { actualConfigPath = configPath; } else if (typeof configPath === 'object') { actualOptions = configPath; } if (!editor) { throw new Error(`Check parameters`); } assert.ok(actualConfigPath, 'A config filepath is required to create a storage'); this.path = actualConfigPath; this.name = actualName ?? actualOptions.name; this.fs = editor; this.indent = 2; this.lodashPath = actualOptions.lodashPath ?? false; this.disableCache = actualOptions.disableCache ?? false; this.disableCacheByFile = actualOptions.disableCacheByFile ?? false; this.sorted = actualOptions.sorted ?? false; this.transform = actualOptions.transform; // The cache is checked against the contents of the file when it is read, without a listener: a storage is created // by generator, and a listener each made every change of the store call every storage. this.existed = Object.keys(this._store).length > 0; } /** * The contents of the storage file in the mem-fs store. */ get fileContents() { return this.fs.store.get(this.path).contents; } clearCache() { delete this._cachedStore; this._cachedContents = undefined; } /** * Convert the contents of the storage file to the store, JSON by default. * Override with `serializeContent` and `defaultContent` to store another format. * @protected * @param contents the contents of the storage file * @return the store content */ parseContent(contents) { try { return JSON.parse(contents); } catch (error) { const detail = error instanceof Error ? error.message : String(error); throw new Error(`Could not parse JSON in file: ${this.path}. Detail: ${detail}`, { cause: error }); } } /** * Convert the store to the contents of the storage file, JSON by default. * @protected * @param fullStore the store content * @return the contents of the storage file */ serializeContent(fullStore) { return `${JSON.stringify(fullStore, undefined, this.indent)}\n`; } /** * The store content of a storage file that does not exist, an empty object by default. * @protected * @return the store content */ defaultContent() { return {}; } /** * @protected * @return the store content */ readContent() { const content = this.fs.exists(this.path) ? this.parseContent(this.fs.read(this.path)) : this.defaultContent(); if (!content || typeof content !== 'object' || Array.isArray(content)) { throw new Error(`${this.path} is not a valid Storage`); } return content; } /** * @protected * @return the store content */ writeContent(fullStore) { const contents = Buffer.from(this.serializeContent(fullStore)); // The file keeps this buffer as its contents: the cache is kept for it only, a write nested in the change // notification of this one gives the file other contents and makes it outdated. this._cachedContents = this.disableCache ? undefined : contents; this.fs.write(this.path, contents); if (this._cachedContents) { // A write of the contents the file already has leaves the file as it is, with its own buffer. const { fileContents } = this; if (fileContents && fileContents !== contents && fileContents.equals(contents)) { this._cachedContents = fileContents; } } return contents.toString(); } /** * Return the current store as JSON object * @return the store content * @private */ get _store() { if (this._cachedStore !== undefined && this.fileContents !== this._cachedContents) { this.clearCache(); } let store = this._cachedStore; if (store === undefined) { const contents = this.fileContents; store = this.readContent(); if (!this.disableCache) { this._cachedStore = store; this._cachedContents = contents; } } if (!this.name) { return store; } return ((this.lodashPath ? get(store, this.name) : store[this.name]) ?? {}); } /** * Persist a configuration to disk * @param val - current configuration values * @private */ _persist(value) { if (this.sorted) { value = sortKeys(value, { deep: true }); } let fullStore; if (this.name) { fullStore = this.readContent(); if (this.lodashPath) { set(fullStore, this.name, value); } else { fullStore[this.name] = value; } } else { fullStore = value; } // What is written is the cache, so the objects read from this storage before the write, like a nested object of its // proxy, are still the stored ones; it is kept for the contents written only. if (!this.disableCache) { this._cachedStore = fullStore; } this.writeContent(fullStore); } /** * Save a new object of values */ save() { this._persist(this._store); } /** * Get a stored value * @param key The key under which the value is stored. * @return The stored value. Any JSON valid type could be returned */ get(key, { raw } = {}) { if (raw) { return this._store[key]; } return this.transformedStore()[key]; } /** * Get a stored value from a lodash path * @param path The path under which the value is stored. * @return The stored value. Any JSON valid type could be returned */ getPath(path) { return get(this.transformedStore(), path); } /** * Get all the stored values * @return key-value object */ getAll() { return this.transformedStore({ forceClone: true }); } set(key, value) { const store = this._store; let ret; if (typeof key === 'object') { ret = Object.assign(store, key); } else if (typeof key === 'string' || typeof key === 'number') { store[key] = value; ret = value; } else { throw new TypeError(`key not supported ${typeof key}`); } this._persist(store); return ret; } /** * Assign a lodash path to a value and schedule a save. * @param path The key under which the value is stored * @param val Any valid JSON type value (String, Number, Array, Object). * @return val Whatever was passed in as val. */ setPath(path, value) { assert.ok(typeof value !== 'function', "Storage value can't be a function"); const store = this._store; set(store, path, value); this._persist(store); return value; } /** * Delete a key from the store and schedule a save. * @param key The key under which the value is stored. */ delete(key) { const store = this._store; delete store[key]; this._persist(store); } /** * Setup the store with defaults value and schedule a save. * If keys already exist, the initial value is kept. * @param defaults Key-value object to store. * @return val Returns the merged options. */ defaults(defaults) { assert.ok(typeof defaults === 'object', 'Storage `defaults` method only accept objects'); const store = setDefaults({}, this._store, defaults); this._persist(store); return this.getAll(); } /** * @param defaults Key-value object to store. * @return val Returns the merged object. */ merge(source) { assert.ok(typeof source === 'object', 'Storage `merge` method only accept objects'); const value = merge({}, this._store, source); this._persist(value); return this.getAll(); } createStorage(path) { const childName = this.name ? `${this.name}.${path}` : path; // A child of a subclass, like a storage of another format, is of the same class: a subclass keeps the // `(name, fs, configPath, options)` constructor or overrides createStorage. const StorageClass = this.constructor; return new StorageClass(childName, this.fs, this.path, { lodashPath: true, transform: this.transform }); } /** * Creates a proxy object. * @return proxy. */ createProxy() { return new Proxy(this, proxyHandler); } transformedStore({ forceClone = false } = {}) { if (this.transform) { return this.transform(cloneDeep(this._store), this.name); } if (forceClone) { return cloneDeep(this._store); } return this._store; } } export default Storage;