UNPKG

@kwaeri/xfmr

Version:

The @kwaeri/xfmr component module of the @kwaeri application framework.

575 lines 27.7 kB
/******************************************************************************* * @module kwaeri/xfmr * @version 0.4.0 * @license * Copyright © 2014 - 2022 Richard Winters <kirvedx@gmail.com> and contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * * SPDX-License-Identifier: Apache-2.0 ******************************************************************************/ 'use strict'; // INCLUDES import { kdt } from '@kwaeri/developer-tools'; import { ServiceProvider } from '@kwaeri/service'; import { Configuration } from '@kwaeri/configuration'; import { glob } from "glob"; import * as fs from 'fs'; import * as _fs from 'fs/promises'; import * as path from 'path'; import VersionBump from './transformations/version-bump.mjs'; import debug from 'debug'; /* Configure Debug module support */ const DEBUG = debug('kue:xfmr'); /* Prepare KDT */ const _ = new kdt(); // Example of progress bar usage: //progress.update( 25 ); progress.log( `Added 25% progress.` ).notify( "Adding another 25% progress.." ); //progress.updateAndNotify( 50, "Adding, again, another 25% progress..." ); // DEFINES const TRANSFORMATION_TYPES = { BUMP: "bump", REPLACE: "replace" }; const BUMP_TRANSFORMATIONS = { VERSION: "version", COPYRIGHT: "copyright", }; const DEFAULT_TRANSFORMER_OPTIONS = { environment: "default", quest: "bump", specification: "project-version", args: { // "step-back": "5", }, subCommands: [], version: "", configuration: { project: { name: "", type: "", tech: "", root: ".", author: { first: "", last: "", fullName: "", email: "" }, copyright: "", copyrightEmail: "", license: { identifier: "" }, repository: "" } }, xfmOptions: { version: { byType: undefined, toVersion: undefined, projectFlag: undefined, sourceFlag: undefined, testFlag: undefined, }, copyright: { copyrightType: "range", toYear: undefined, testFlag: undefined } }, xfmConf: { source: { match: [], globOptions: { "base": "./" }, key: { version: " * @version ", copyright: " * Copyright © " }, destination: undefined, testDestination: "tests" }, module: { match: [], globOptions: { "base": "./" }, key: "\"version\": \"", destination: undefined, testDestination: "tests" } } }; /** * The Transformer class is a replacement for gulp-bump-version within * our newly redesigned project template that excludes gulp altogether * as the primary build tool in favor of our own end-to-end tooling. * * The service provider can also be used as a template for other similar * service provider types. */ export class Transformer extends ServiceProvider { /** * @var { Configuration } */ configuration; /** * Class constructor */ constructor(handler, configuration) { super(handler); // Organize all of the uncertainty: //let environment = ( configuration && configuration.environment ) ? configuration.environment : "default"; // Instantiate a new configuration object to wrap the migrations configuration: this.configuration = new Configuration(undefined, ".xfmrc"); } getServiceProviderSubscriptions(options) { return { commands: { "bump": { "version": false, // The project specification has a required flag (type) "copyright": false } }, required: { "bump": { "version": { // for the project specifications of the 'new' command: //"type": [ // The flag's possible acceptable values: // "api", // "react" //] }, "copyright": {} } }, optional: { "bump": { "version": { "component": { "for": false, // Or true, if it related to an option/value, rather only to the specification. "flag": false, // True insists that no value is given. Its existance equates to <option>=1, the lack of its "values": [ "major", // Pairs with --bump-version to specify which semver component to bump, leaving this option out when specifying "minor" // --bump-version indicates the patch component ] }, "to-version": { "for": false, // Or true, if it related to an option/value, rather only to the specification. "flag": false, // True insists that no value is given. Its existance equates to <option>=1, the lack of its "values": ["*"] // Indicates any string value is expected (though internal logic will validate it) }, "project": { "for": false, "flag": true }, "source": { "for": false, "flag": true } }, "copyright": { "type": { "for": false, "flag": false, "values": [ "range", // Default "each", "current" ] } } } } }; } getServiceProviderSubscriptionHelpText(options) { return { helpText: { "commands": { "bump": { "description": "The 'bump' command automates semver transformation.", "specifications": { "version": { "description": "Bumps the semver string within source files of the project, and according to options provided.", "options": { "required": { // ⇦ Required options are specific to the specification, 'project' in this case, //"type": { // ⇦ For the "type" required option // "react": { // ⇦ For the required options value, can be 'any' // "redux": { // ⇦ List options // "description": "Denotes that the project should include redux support", // "values": false // } // } //} }, "optional": { "specification": { "component": { "description": "Denotes that the version string should be bumped based on a component.", "values": [ "major", "minor" ] }, "to-version": { "description": "Denotes that the version string should be set to a specific version.", "values": [ "Any semantic version (i.e. x.x.x)", ] }, "project": { "description": "Denotes that the version should be bumped within the project's `package.json` file by leveraging it's alternate key.", "values": false }, "source": { "description": "Denotes that the version should be bumped within project source files.", "values": false } } // ⇦ For the various required options that allow optional flags } } }, "copyright": { "description": "Bumps the copyright string within source files of the project, and according to options provided.", "options": { "required": {}, "optional": { "specification": { "type": { "description": "Denotes that the copyright string is of the type specified.", "values": [ "range", "each", "current" ] } } } } } }, "options": { "optional": { //"command": { // ⇦ For the command itself // "example-option": { // ⇦ List options // "description": "", // "values": [] // } //}, //"optional-option": { // ⇦ For the optional options of the command // "optional-value": { // ⇦ For the optional options value, can be 'any' // "example-option": { // ⇦ List options // "description": "", // "values": [] // } // } //} } } } } } }; } /** * Method to resettle the { NodeKitProjectGeneratorOptions }. Essentially we * merge NodeKitOptions with FilesystemDescriptor by combining provided * command options with either a stored configuration or sane default. * * @param { ReactComponentGeneratorOptions } options * * @returns { ReactComponentGeneratorOptions } The options object, with the configuration partially populated with user-provided information */ async assembleOptions(options) { DEBUG(`[ASSEMBLE_OPTIONS] Read configuration '.xfmrc'`); const conf = await this.configuration.get(); if (!conf) return Promise.reject(new Error("[ASSEMBLE_OPTIONS] There was an issue reading .xfmrc")); DEBUG(`[ASSEMBLE_OPTIONS] Extend missing settings with defaults`); const xfmConf = _.extend(conf, DEFAULT_TRANSFORMER_OPTIONS.xfmConf); let returnable; returnable = _.extend(options, DEFAULT_TRANSFORMER_OPTIONS); returnable.xfmConf = xfmConf; DEBUG(`[ASSEMBLE_OPTIONS] Sanitize settings`); //returnable.type = ( returnable.args.type in PARAMATERIZATION.TYPE ) ? returnable.args.type : 'mysql'; returnable.xfmOptions.version.toVersion = (returnable.args['to-version']) ? returnable.args['to-version'] : undefined; returnable.xfmOptions.version.byType = (returnable.args['component']) ? returnable.args['component'] : undefined; returnable.xfmOptions.version.sourceFlag = (returnable.args['source']) ? returnable.args['source'] : undefined; returnable.xfmOptions.version.projectFlag = (returnable.args['project']) ? returnable.args['project'] : undefined; returnable.xfmOptions.version.testFlag = (returnable.args['test']) ? returnable.args['test'] : undefined; returnable.xfmOptions.copyright.copyrightType = (returnable.args['copyright-type']) ? returnable.args['copyright-type'] : "range"; returnable.xfmOptions.copyright.toYear = (returnable.args['to-year']) ? returnable.args['to-year'] : undefined; returnable.xfmOptions.copyright.testFlag = (returnable.args['test']) ? returnable.args['test'] : undefined; return Promise.resolve(returnable); } async renderService(options) { this.updateProgress('RENDER_TRANSFORM_SERVICE', { progressLevel: 0, notice: `Preparing to transform ${(options.args['project'] ? `project ` : ``)}${options.specification}` }); try { DEBUG(`[RENDER_TRANSFORM_SERVICE] Resettle options`); const opts = await this.assembleOptions(options); DEBUG(`\n`, `\n`, ` Start transform-files routine:\n`, ` ⇨ Test run: ${opts.xfmOptions.version.testFlag}\n`, ` ⇨ Source bump: ${opts.xfmOptions.version.sourceFlag}\n`, ` ⇨ Module bump: ${opts.xfmOptions.version.projectFlag}\n`, ` ⇨ Bump options:\n `, { type: opts.xfmOptions.version.byType, version: opts.xfmOptions.version.toVersion, key: opts.xfmConf.source.key.version }, `\n`); /** * We'll eventually add other types of transformations than just 'bump version' */ switch (options.quest) { case TRANSFORMATION_TYPES.BUMP: { if (options.specification == BUMP_TRANSFORMATIONS.VERSION) return Promise.resolve(await this.transformTarget(opts)); else { if (options.specification == BUMP_TRANSFORMATIONS.COPYRIGHT) return Promise.resolve({ result: false, type: TRANSFORMATION_TYPES.BUMP, message: `Transformation of type '${options.quest} ${options.specification}' is not yet implemented.` }); } } break; case TRANSFORMATION_TYPES.REPLACE: { return Promise.resolve({ result: false, type: TRANSFORMATION_TYPES.REPLACE, message: `Transformation of type '${options.quest} ${options.specification}' is not yet implemented.` }); } break; default: return Promise.resolve({ result: false, type: "unknown", message: `Transformation of type '${options.quest} ${options.specification}' is not supported.` }); break; } } catch (error) { return Promise.reject(error); } } /** * Method to simplify emitting progress bar events and debugging * * @param tag The tag to note for debugging purposes * @param serviceEventBits The progress bar event metadata to leverage in setting service event metadata * * @returns { void } */ updateProgress(tag, serviceEventBits) { DEBUG(`[${tag}] call 'setServiceEventMetadata' with '${serviceEventBits}`); this.setServiceEventMetadata(serviceEventBits); //this.progressHandler( serviceEventBits ); } /** * Gets an array of string paths returned by the glob processor * * @param { string|string[] } target The string or string array of glob patterns to match * @param { any } options The glob processor's options (see {@link glob}) * * @returns { Promise<string[]> } The promise of an array of string paths */ async getTargetPaths(target, options) { DEBUG(`Getting files for processing`); // Fix non-arrays into arrays const globs = (_.type(target) != "array" && _.type(target) == 'string') ? [target] : target; DEBUG(`Source globs:\n`, `⇨ ${globs}`, `\n`, `Ignore globs:\n`, `⇨ ${options.ignore}`); DEBUG(`Globbing sources`); let paths = []; for (let query in globs) { DEBUG(`⇨ globbing source'${globs[query]}'`); paths = [...paths, ...(await this.globIt(globs[query], options))]; //glob( path.join( ((options.base )?options.base:""), globs[query] ), callback ); } return Promise.resolve(paths); } /** * Wraps `glob` with a promise * * @param { string } target A string pattern for glob to match against * @param { any } options `glob`'s options object * * @returns { string[] } An array of matched PlatformPaths as strings */ async globIt(target, options) { try { let globbed = await glob(target, options); // If all goes as expected, return the promised globbed paths: return Promise.resolve(globbed); } catch (exception) { // Otherwise, reject the promise with the exception: return Promise.reject(exception); } } /** * Applies a transform stream to each target path provided. This method wraps the * process and responds appropriately for the service provider * * @param { XfmrOptions } options * * @returns { Promise<any> } A promise of any - though expect an extended object of type {@link ServicePromiseBits} */ async transformTarget(options) { const { xfmOptions, xfmConf } = options; let source = [], module = []; if (options.xfmOptions.version.sourceFlag) source = await this.process(await this.getTargetPaths(xfmConf.source.match, xfmConf.source.globOptions), xfmOptions.version, xfmConf.source, true); if (options.xfmOptions.version.projectFlag) module = await this.process(await this.getTargetPaths(xfmConf.module.match, xfmConf.source.globOptions), xfmOptions.version, xfmConf.module, true); // TODO: Add support for Copyright bump! return Promise.resolve({ result: true, type: `bump_version`, targets: [...source, ...module] }); } ; /** * A middle-man method that eases readability due to needing to support * several related transformations, each of which have certain catche * cases that differentiate and nuance them from one another. * * A different method is called from here depending on whether we are * transforming the version or the copyright; If the former, we may * make upwards of two calls to support the differentiation between * the version within typical source and that of the module's * package.json file. * * * @param { string[] } files An array of target paths matched for transformation * @param { VersionTransformOptions | CopyrightTransformOptions } opts A {@link VersionTransformOptions} or {@link CopyrightTransformOptions} object * @param { XfmConfigurationBits } conf Eitehr the source or module {@link XfmConfigurationBits} object from the {@link XfmConfiguration} objecct * @param { boolean } version Denotes whether or not we're doing a version transform. Defaults to false * @param { boolean } copyright Denotes whether or not we're doing a copyright transform. Defaults to false * * @returns { Promise<string[]> } The promise of an array of strings */ async process(files, opts, conf, version = false, copyright = false) { const totalFiles = files.length; let processed = [], lastFile = ""; // Process each target for (let file in files) { // Ensure the file destination path exists: const target = await this.satisfyDestFs(files[file].toString(), opts.testFlag, conf.testDestination, conf.destination); // Do not await this within assignment, it will not run in context! this.updateProgress("XFMR", { progressLevel: ((parseInt(file) / totalFiles) * 100), notice: `Processing '${files[file]}'` }); if (lastFile && lastFile !== "") this.updateProgress("XFMR", { log: `Processed '${lastFile}'` }); const transformed = await this.processFile(files[file].toString(), target, conf, opts, version, copyright); this.updateProgress("XFMR", { progressLevel: ((parseInt(file) / totalFiles) * 100), notice: `Processing '${files[file]}'` }); // Assign only after the file has been processed! processed.push(transformed); lastFile = files[file].toString(); } return Promise.resolve(processed); } /** * Ensures that the destination for a target exists so that the * target can be written . * * @param { string } target The path to the target, relative the cwd * @param { boolean } test Denotes whether this is a test run * @param { string } destination The destination of the target, relative to cwd * * @return { Promise<string> } A promise of the new string path to target */ async satisfyDestFs(target, test = false, testDestination = "tests", destination = "") { // Add a new base to the path if necessary: const dest = (test && path.join(testDestination, target)) || path.join(destination, target); DEBUG(`Verify target destination '${dest}'`); // Split the path to the target by the directory separator const ancestors = dest.split("/"); DEBUG(`Target destination ancestors: `); DEBUG(ancestors); // Let's build a path to the parent of the target, if it's not // the wurrent working directory if (ancestors.length > 1) { DEBUG(`Assure target destination path`); // Remove the file name, which should be the last // array index const file = ancestors.pop(); DEBUG(`Pop target '${file}' from ancestry `); // If undefined was not returned, there was a child directory // in our current working directory that is home to the target. // We need to make sure that that path exists, we'll call // mkdir with recursive set to true; There's no error if // the directory already exists, and it makes all parent paths: await _fs.mkdir(path.join(...ancestors), { recursive: true }); // Otherwise we don't need to do anything else } return Promise.resolve(dest); } /** * Applies a transform stream between a read and write stream so as to bump * the semantic version string found within each target according to options * provided. * * @param { string } file The string path of the target file * @param { XfmConfigurationBits } conf A {@link XfmConfigurationBits} object * @param { VersionTransformOptions } options A {@link VersionTransformOptions} object * @param { boolean } version Denotes whether to transform the version * @param { boolean } copyright Denotes whether to transform the copyright * * @returns { string } The string destination path of the target */ async processFile(source, target, conf, options, version = false, copyright = false) { try { DEBUG(`Read source '${source}'`); const read = await fs.promises.readFile(source, { encoding: "utf8" }); DEBUG(`Transform data`); const output = this.transformData(read, conf, options, version, copyright); DEBUG(`Write source '${target}'`); await fs.promises.writeFile(target, output, { encoding: "utf8" }); } catch (exception) { DEBUG(exception); return Promise.reject(exception); } DEBUG(`Resolve processed file '${source}'`); return Promise.resolve(source); } ; /** * Method intended to apply the correct transformation of those available, based on the request * * @param { string } data The read in data * @param { XfmConfigurationBits } conf A {@link XfmConfigurationBits} object * @param { VersionTransformOptions } options A {@link VersionTransformOptions} object * @param { boolean } version Denotes whether to transform the version * @param { copyright } copyright Denotes whether to transform the copyright * * @returns { string } The transformed data. If an error is caught, its `throw`n */ transformData(data, conf, options, version = false, copyright = false) { const { toVersion, byType, } = options, key = options.projectFlag ? conf.key : conf.key.version; DEBUG(`Set version bump options`); const opts = toVersion ? // If we're setting "to version", then { version: toVersion, key: key } : byType ? // Otherwise, check if it is being set "by component" { type: byType, key: key } : { type: "patch", key: key }; // Fallback on patch bump. const bumper = new VersionBump(); DEBUG(`Apply version transformation`); const output = bumper.bumpVersion(data, opts); const error = (output == -3) ? new Error(`ERROR: You must provide an options argument as an object with a property of version or type, appropriately set for modifying file versions as you prefer. \n` + `Visit http://gitlab.com/mmod/gulp-bump-version#basic-usage-examples for more information.\n\n`) : null; DEBUG(`Error?: ${error}`); if (error !== null) throw error; DEBUG(`Return Transformed data`); return output; } } //# sourceMappingURL=xfmr.mjs.map