UNPKG

templates-mo

Version:

Templates is a scaffolding framework that makes code generation simple, dynamic, and reusable. Generate files, parts of your app, or whole project structures—without the repetitive copy-pasting

766 lines (643 loc) • 19.3 kB
/* eslint-disable max-classes-per-file */ // eslint-disable-next-line @typescript-eslint/ban-ts-comment import * as path from 'path'; import fs from 'fs'; import * as is from 'is'; import { DirNode, FileNode, FileSystemNode } from '@tps/fileSystemTree'; import * as TPS from '@tps/utilities/constants'; import { cosmiconfigAllExampleSync, findUp, isDir, isDirAsync, } from '@tps/utilities/fileSystem'; import Prompter from '@tps/prompter'; import { eachObj, hasProp, getNpmPaths, getAllDirectoriesAndUp, stripPrefix, } from '@tps/utilities/helpers'; import { TemplateNotFoundError, RequiresTemplateError, PackageAlreadyCompiledError, DirectoryNotFoundError, NoPromptsError, } from '@tps/errors'; import logger from '@tps/utilities/logger'; import * as colors from 'ansi-colors'; import dot from '@tps/templates/dot'; import templateEngine from '@tps/templates/template-engine'; import { TemplateOptions } from '@tps/types/templates'; import { Tpsrc } from '@tps/types/tpsrc'; import { AnswersHash, SettingsFile } from '@tps/types/settings'; import { cosmiconfigSync, defaultLoadersSync, getDefaultSearchPlacesSync, } from 'cosmiconfig'; import { Build } from './build'; import { Template } from './template'; import File from './File'; interface BuildErrors { error: Error; build: Build; didBuildPathExist: boolean; } export const DEFAULT_OPTIONS: TemplateOptions = { noLocalConfig: false, noGlobalConfig: false, defaultPackage: true, default: false, hidden: false, force: false, newFolder: true, wipe: false, tpsPath: null, extendedDest: '', experimentalTemplateEngine: true, }; if (TPS.IS_TESTING) { logger.tps.opts.disableLog = true; } FileSystemNode.ignoreFiles = ['**/.gitkeep', '**/.tpskeep']; const settingsConfig = cosmiconfigSync(TPS.TEMPLATE_SETTINGS_FILE, { cache: !TPS.IS_TESTING, searchPlaces: [ `${TPS.TEMPLATE_SETTINGS_FILE}.json`, `${TPS.TEMPLATE_SETTINGS_FILE}.js`, ], }); const tpsConfigName = 'tps'; const defaultTpsrcSearches = getDefaultSearchPlacesSync(tpsConfigName); const nestedTpsrcSearches = defaultTpsrcSearches.map((location) => { return `.tps/${location}`; }); /** * TODO: Remove these from the list * - .tps/.config/tpsrc.cjs * - .tps/.config/tpsrc.ts * - .tps/.config/tpsrc.js * - .tps/.config/tpsrc.yml * - .tps/.config/tpsrc.yaml * - .tps/.config/tpsrc.json * - .tps/.config/tpsrc * - .tps/package.json */ const tpsrcSearchPlaces = [...defaultTpsrcSearches, ...nestedTpsrcSearches]; const tpsrcConfig = cosmiconfigSync(tpsConfigName, { cache: !TPS.IS_TESTING, searchStrategy: 'global', loaders: defaultLoadersSync, searchPlaces: tpsrcSearchPlaces, }); // eslint-disable-next-line @typescript-eslint/no-explicit-any type RenderData = Record<string, any>; /** * @class * @classdesc Create a new instance of a template */ export class Templates<TAnswers extends AnswersHash = AnswersHash> { /** * name of template */ public template: string; /** * Templates options */ public opts: TemplateOptions; public packages: Record<string, DirNode>; public packagesUsed: string[]; private _defs: Record<string, string>; public successfulBuilds: SuccessfulBuild; public buildErrors: BuildErrors[]; public templateSettings: SettingsFile; // eslint-disable-next-line @typescript-eslint/no-explicit-any public engine: any; // public engine: typeof templateEngine | typeof (doT as any); /** * Path to the templates settings file */ public templateSettingsPath: string; /** * Path to the templates directory */ public src: string; public _prompts?: Prompter<TAnswers>; public compiledFiles: File[]; /** * All tpsrc config file names. * * @example * * [ * '.tps/tps.config.cjs', * '.tps/tps.config.ts', * '.tps/tps.config.js', * '.tps/.tpsrc.cjs', * '.tps/.tpsrc.ts', * '.tps/.tpsrc.js', * '.tps/.tpsrc.yml', * '.tps/.tpsrc.yaml', * '.tps/.tpsrc.json', * '.tps/.tpsrc', * 'tps.config.cjs', * 'tps.config.ts', * 'tps.config.js', * '.config/tpsrc.cjs', * '.config/tpsrc.ts', * '.config/tpsrc.js', * '.config/tpsrc.yml', * '.config/tpsrc.yaml', * '.config/tpsrc.json', * '.config/tpsrc', * '.tpsrc.cjs', * '.tpsrc.ts', * '.tpsrc.js', * '.tpsrc.yml', * '.tpsrc.yaml', * '.tpsrc.json', * '.tpsrc', * 'package.json' * ] */ public static readonly tpsrcConfigNames: string[] = tpsrcSearchPlaces; /** * Get all locations a template can be * * Templates can be in be: * - any `.tps/` directory from the callers cwd and any directory above it * - Any `node_module` directory from the callers cwd and any directory above it */ public static getTemplateLocations(cwd: string = TPS.CWD): string[] { const tpsDirectoryLocations = getAllDirectoriesAndUp(cwd).map((dir) => { return path.join(dir, TPS.TPS_FOLDER); }); // TODO: Sort this by directory return [ ...tpsDirectoryLocations, path.join(TPS.MAIN_DIR, TPS.TPS_FOLDER), ...getNpmPaths(cwd), ]; } /** * Get the path to a template or null if template doesnt exist */ public static findTemplate( templateName: string, cwd: string = TPS.CWD, ): string | null { const homeDirectory = Templates.getTemplateLocations(cwd).find((tpsDir) => { return isDir(path.join(tpsDir, templateName)); }); if (!homeDirectory) return null; return path.join(homeDirectory, templateName); } /** * Gets path to the global .tps/ directory */ public static getGloablTpsPath(): string | null { return path.join(TPS.USER_HOME, TPS.TPS_FOLDER); } public static getLocalTpsPath(): string | null { const tpsLocal: string = findUp(TPS.TPS_FOLDER, TPS.CWD); const hasLocalTpsFolder = tpsLocal && tpsLocal !== TPS.GLOBAL_PATH; if (!hasLocalTpsFolder) return null; return tpsLocal; } public static directoryIsTpsInitialized(dir): boolean { return isDir(path.join(dir, TPS.TPS_FOLDER)); } public static hasGloablTps(): boolean { return Templates.directoryIsTpsInitialized(TPS.USER_HOME); } public static hasLocalTps(): boolean { return !!Templates.getLocalTpsPath(); } constructor(templateName: string, opts: Partial<TemplateOptions> = {}) { if (!templateName || !is.string(templateName)) { throw new RequiresTemplateError(); } this.template = templateName; const templateLocation = Templates.findTemplate(templateName) || Templates.findTemplate(`tps-${templateName}`); if (!templateLocation) { logger.tps.error('Template not found! \n%O', { searchedPaths: Templates.getTemplateLocations(), }); throw new TemplateNotFoundError(templateName); } this.src = templateLocation; logger.tps.info('Template %n', { name: this.template, location: this.src, }); this.packages = {}; this.packagesUsed = []; this.compiledFiles = []; this._defs = {}; this.successfulBuilds = new SuccessfulBuild(); this.buildErrors = []; this.templateSettings = {} as SettingsFile; this.templateSettingsPath = path.join(this.src, TPS.TEMPLATE_SETTINGS_FILE); logger.tps.info('Settings file location: %s', this.templateSettingsPath); try { logger.tps.info('Loading template settings file...'); // eslint-disable-next-line this.templateSettings = settingsConfig.search(this.src)?.config || {}; } catch (e) { logger.tps.info(`Template has no Settings file`, e); this.templateSettings = {} as SettingsFile; } logger.tps.info('Template settings: %n', this.templateSettings); this.opts = { // default options ...DEFAULT_OPTIONS, // template settings options ...(this.templateSettings?.opts || {}), // tpsrc ?? // user options ...opts, }; this.engine = this.opts.experimentalTemplateEngine ? templateEngine : dot; logger.tps.info('Template Options: %n', this.opts); if (this.templateSettings.prompts) { logger.tps.info('Loading prompts... %o', { defaultValues: this.opts.default, showHiddenPrompts: this.opts.hidden, }); this._prompts = new Prompter<TAnswers>(this.templateSettings.prompts, { default: this.opts.default, showHiddenPrompts: this.opts.hidden, }); } else { logger.tps.info('No prompts to load!', this.templateSettings); } this._loadTpsrc(templateName); // load default package if applicable const defaultFolder = path.join(this.src, 'default'); const shouldLoadDefault = this.opts.defaultPackage && isDir(defaultFolder); logger.tps.info('Loading default package %n', { shouldLoadDefault, defaultLocation: defaultFolder, }); if (shouldLoadDefault) { this.loadPackage('default'); } } public hasGloablTps(): boolean { return Templates.hasGloablTps(); } public hasLocalTps(): boolean { if (!this.opts.tpsPath) { return Templates.hasLocalTps(); } return isDir(this.opts.tpsPath); } /** * Include packages to use in the render process */ loadPackages(newPackages: string | string[]): void { let packages = newPackages; if (!Array.isArray(packages)) { if (is.string(packages) && packages) { packages = [packages]; } else { throw new TypeError('Argument must be a string or an array of stings'); } } packages.forEach((p) => this.loadPackage(p)); } /** * @param {String} newPackage - package from the template you would like to use */ loadPackage(newPackageName: string): void { if (!this.src) { throw new RequiresTemplateError(); } if (!is.string(newPackageName)) { throw new TypeError('Argument must be a string'); } if (hasProp(this.packages, newPackageName)) { throw new PackageAlreadyCompiledError(newPackageName); } this.packages[newPackageName] = new DirNode(newPackageName, this.src); logger.tps.info('Loading package %s', newPackageName); this._compileFilesFromPackage(newPackageName); logger.tps.success('Added package %s', newPackageName); this.packagesUsed.push(newPackageName); } /** * Get directory tree representation of package */ pkg(packageName: string): DirNode { return this.packages[packageName]; } /** * Set answers for prompts */ hasPrompts(): boolean { return !!(this._prompts && this._prompts.hasPrompts()); } /** * Get answers */ getAnswers(): TAnswers { return this._prompts.answers; } /** * Set answers for prompts * @param answers - object of prompts answers. Key should be the name of the prompt and value should be the answer to it */ setAnswers(answers: Partial<TAnswers>): void { if (!this.hasPrompts()) { throw new NoPromptsError(); } this._prompts.setAnswers(answers); } /** * @param dest - destination to render your new template to * @param buildPaths - Instances you would like to create * @param data - data to pass to doT. This will be used when rendering dot files/syntax * @returns {Promise} */ async render<T extends string | string[]>( dest: string, buildPaths?: T, data: RenderData = {}, ): Promise<T extends string[] ? string[] : string> { let buildInDest = false; let pathsToCreate: string[]; let finalDest = dest; if (!buildPaths) { buildInDest = true; pathsToCreate = ['./']; } else if (typeof buildPaths === 'string') { pathsToCreate = [buildPaths]; } else { pathsToCreate = buildPaths; } // @ts-expect-error need to fix library if (is.array.empty(buildPaths)) { throw new Error( 'Param `buildPaths` need to be a string or array of strings', ); } // if were building in the destination. then we aren't creating any new folders const buildNewFolder = buildInDest ? false : this.opts.newFolder; logger.tps.info('Build paths: %n', pathsToCreate); // Append dest config if (this.opts.extendedDest) { finalDest = path.join(dest, this.opts.extendedDest); } // Create absolute paths pathsToCreate = pathsToCreate.map((buildPath) => path.join(finalDest, buildPath), ); logger.tps.info('Rendering templates to locations %n', pathsToCreate); if (!(await isDirAsync(finalDest))) { logger.tps.error('final destination was not a directory %n', { finalDest, }); throw new DirectoryNotFoundError(finalDest); } await this._answerRestOfPrompts(); logger.tps.info('Rendering template at %s', finalDest); const template = new Template( this.template, this.src, this.templateSettings, this.packages, this.packagesUsed, this.compiledFiles, this._defs, ); await this._emitEvent('onRender', { dest: finalDest, buildPaths: pathsToCreate, hasBuildPaths: !buildInDest, createFile: (name: string, content: string) => { template.createFile(name, content, { force: this.opts.force }); }, createDirectory: (dir: string) => { template.createDirectory(dir); }, }); const builders: Promise<void>[] = pathsToCreate.map((buildPath) => { const build = new Build(buildPath, template, { buildInDest, buildNewFolder, wipe: this.opts.wipe, force: this.opts.force, }); return this._renderBuildPath(build, data); }); await Promise.all(builders); // TODO: When a event fails should we clean up the build path? await this._emitEvent('onRendered', { dest: finalDest, buildPaths: pathsToCreate, }); // @ts-expect-error need to fix library if (is.array.empty(this.buildErrors)) { logger.tps.success('Finished rendering templates'); // @ts-expect-error Not sure whats wrong here return Array.isArray(buildPaths) ? pathsToCreate : pathsToCreate[0]; } logger.tps.info('Build Errors: %o', this.buildErrors.length); logger.tps.info( 'Build Paths need to be cleaned %n', this.buildErrors.map(({ build }) => build.getDirectory()), ); await Promise.all( this.buildErrors.map(async ({ build, didBuildPathExist }) => { await build.clean(buildNewFolder && !didBuildPathExist); }), ); const errors = this.buildErrors.map(({ error }) => error); return Promise.reject(errors.length === 1 ? errors[0] : errors); } private async _renderBuildPath( build: Build, data: RenderData, ): Promise<void> { const loggerGroup = build.getLogger(); const doesBuildPathExist = await build.directoryExists(); try { await this._emitEvent('onBuildPathRender', { buildPath: build.buildPath, }); const answers = this.hasPrompts() ? this._prompts.answers : {}; await build.render(answers, data); } catch (err) { loggerGroup.error('Build Path: %s %n', build.buildPath, err); this._scheduleCleanUpForBuild(build, err, doesBuildPathExist); } finally { logger.tps.printGroup(build.getLoggerName()); await this._emitEvent('onBuildPathRendered', { buildPath: build.buildPath, }); } } _scheduleCleanUpForBuild( build: Build, err: Error, didBuildPathExist: boolean, ): void { build .getLogger() .info('Build Path schedule for cleaning %s %o', build.buildPath, { didBuildPathExist, }); this.buildErrors.push({ build, error: err, didBuildPathExist, }); } /** * Compile all files that need to be made for render process * @private * @param {String} packageName - name of package */ _compileFilesFromPackage(packageName: string): void { const pkg = this.pkg(packageName); const { force } = this.opts; const defFiles = pkg.find({ type: 'file', ext: '.def' }); // @ts-expect-error need to fix library if (!is.array.empty(defFiles)) { logger.tps.info('Compiling def files %o', { force }); defFiles.forEach((fileNode) => { logger.tps.info( ` - %s ${colors.green.italic('compiled')}`, fileNode.name, ); const name = fileNode.name.substring(0, fileNode.name.indexOf('.')); this._defs[name] = fs.readFileSync(fileNode.path).toString(); // When def files have more than one def. In order to use them we need to call the main file def first. // this fixes problems when any def can be available at render time this.engine.template(`{{#def.${name}}}`, null, this._defs); }); } logger.tps.info('Compiling files %n', { force, useExperimentalTemplateEngine: this.opts.experimentalTemplateEngine, }); pkg .find({ type: 'file', ext: { not: '.def' } }) .forEach((fileNode: FileNode) => { const file = File.fromFileNode(fileNode, { force, useExperimentalTemplateEngine: this.opts.experimentalTemplateEngine, }); logger.tps.info( ` - %s ${colors.green.italic('compiled')}`, fileNode.path, ); this.compiledFiles.push(file); }); } async _answerRestOfPrompts(): Promise<void> { if (!this._prompts) return; const answers = await this._prompts.getAnswers(); logger.tps.info('Answers from prompts %n', answers); eachObj(answers, (answer, answerName) => { if (this._prompts.getPrompt(answerName).isPkg()) { switch (true) { // @ts-expect-error need to fix library case is.undef(answer): case answer === null: break; // @ts-expect-error need to fix library case is.bool(answer): if (answer) { this.loadPackage(answerName); } break; case is.string(answer) && !!answer.length: this.loadPackage(answer); break; // @ts-expect-error need to fix library case is.array(answer) && !is.array.empty(answer): this.loadPackages(answer); break; default: throw new Error( `Data type '${typeof answer}' is not supported as answer to a tps prompt`, ); } } }); } /** * Configurations */ _loadTpsrc(templateName: string): void { const tpsrcfiles = cosmiconfigAllExampleSync( TPS.CWD, tpsrcConfig, tpsrcSearchPlaces, ); if (is.empty(tpsrcfiles)) { logger.tps.info('No tps files to find: %n', { cwd: TPS.CWD, tpsrcSearchPlaces, }); } tpsrcfiles.reverse().forEach((tpsrc) => { if (!tpsrc || tpsrc?.isEmpty) return; logger.tps.info('Loading tpsrc from: %s %n', tpsrc.filepath, tpsrc); this._loadTpsSpecificConfig(templateName, tpsrc.config); }); } private _loadTpsSpecificConfig(templateName: string, config: Tpsrc): void { const templateConfig = config[templateName] ?? config[`tps-${templateName}`] ?? config[stripPrefix(templateName, 'tps-')] ?? null; if (templateConfig && is.object(templateConfig)) { logger.tps.info('Loading configuration: %n', templateConfig); const { answers = {}, opts = {} } = templateConfig; this.opts = { ...this.opts, ...opts, }; if (is.object(answers) && !is.empty(answers)) { // TODO: Is this the best way to handle this? this.setAnswers(answers as TAnswers); } } } private async _emitEvent<TEvent extends keyof SettingsFile['events']>( event: TEvent, ...args: Parameters<SettingsFile['events'][TEvent]> extends [ Templates, ...infer Rest, ] ? Rest : never ): Promise<void> { logger.tps.info(`Running event ${event}`); const events = this.templateSettings?.events ?? null; if (events && event in events && typeof events[event] === 'function') { logger.tps.info(`Running ${event} function...`); // @ts-expect-error idk lol await events[event]?.(this, ...args); } } } class SuccessfulBuild { /** * Paths of files that were successfully built */ public files: string[] = []; /** * Paths of directories that were successfully built */ public dirs: string[] = []; } export { TemplateOptions } from '@tps/types/templates';