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
549 lines (468 loc) • 14.8 kB
text/typescript
import colors from 'ansi-colors';
import * as path from 'path';
import { promises as fs } from 'fs';
import DirectoryNode from '@tps/fileSystemTree';
import CreateDebugGroup from '@tps/utilities/logger/createDebugGroup';
import logger from '@tps/utilities/logger';
import { isDirAsync, isFileAsync } from '@tps/utilities/fileSystem';
import { BuildError, FileExistError } from '@tps/errors';
import { AnswersHash } from '@tps/types/settings';
import * as utils from './utils';
import type { Template } from './template';
interface BuildBuilt {
files: string[];
directories: string[];
}
interface BuildOptions {
buildInDest: boolean;
buildNewFolder: boolean;
wipe: boolean;
force: boolean;
}
const DEFAULT_OPTS: BuildOptions = {
buildInDest: false,
buildNewFolder: true,
wipe: false,
force: false,
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type RenderData = Record<string, any>;
export class Build {
/**
* Name of the build if present.
*
* Builds that are being created in the destination dont have names.
*/
public readonly name: string;
/**
* Directory to render the contents into.
*
* If `buildInNewFolder` is `true`, then a directory of `name`
* will be created in this directory and contents will be rendered in that directory.
* Else contents will be rendered in `directory`
*/
public readonly directory: string;
/**
* Files and directories that were created during render
*/
public built: BuildBuilt = { files: [], directories: [] };
public options: BuildOptions;
constructor(
/**
* Full absolute build path
*
* @example "/Users/lornelas/Templates/my-instance"
* @example "/Users/lornelas/Templates/some/extra/path/my-instance"
* @example "/Users/lornelas/Templates"
*/
public readonly buildPath: string,
public readonly template: Template,
options: Partial<BuildOptions> = {},
) {
// should only happen if build in folder is false
// if (buildNewFolder) {
const { name, dir } = path.parse(buildPath);
// TODO: when `buildInDest` is true, `name` should be null
this.name = name;
this.directory = dir;
// }
this.options = {
...DEFAULT_OPTS,
...options,
};
}
/**
* Final directory to create instance contents in.
*
* If `buildInDest` or `buildNewFolder` then we use the supplied buildPath. Note!
* when `buildInDest` is true, the build path wont have a instance name.
*
* TODO: when `buildInDest` is true, `name` should be null
*/
public getDirectory() {
return this.options.buildInDest || this.options.buildNewFolder
? this.buildPath
: this.directory;
}
/**
* Checks to see if the final directory exists or not
*/
public async directoryExists(): Promise<boolean> {
return isDirAsync(this.getDirectory());
}
public async createDirectory() {
return fs.mkdir(this.getDirectory(), { recursive: true });
}
/**
* Destroy the final directory
*/
private async wipe(): Promise<void> {
// we can only remove a directory thats going to be built.
if (this.options.buildInDest || !this.options.buildNewFolder) {
throw new Error(
'Cannot wipe directory that is being build in dest or without a new folder',
);
}
await fs.rm(this.getDirectory(), { force: true, recursive: true });
}
/**
* Wipes the directory if it should. Will return a boolean on whether or not
* directory was wiped.
*/
public async maybeWipe(
hackyCallbackWhenFilesNeedToBeWiped?: () => void,
): Promise<boolean> {
const loggerGroup = this.getLogger();
if (await this.directoryExists()) {
/**
* If `wipe=true` then we need to delete the directory that we will be overriding.
* But if `newFolder=false` then we need to skip the wipe command because we are not creating a new directory.
*/
if (this.options.wipe && !this.options.buildInDest) {
if (!this.options.buildNewFolder) {
loggerGroup.info(
'Skipping wipe because we are not building a new folder',
);
hackyCallbackWhenFilesNeedToBeWiped?.();
return false;
}
loggerGroup.info('Wiping destination %s', this.getDirectory());
await this.wipe();
return true;
}
} else {
loggerGroup.info('Build path does not exist...');
}
return false;
}
public getLoggerName(): string {
return `render_${this.buildPath}`;
}
public getLogger(clear: boolean = false): CreateDebugGroup {
return logger.tps.group(this.getLoggerName(), {
clear,
});
}
public async checkForConflicts(
dest: string,
data: RenderData,
): Promise<void> {
const { compiledFiles, defs } = this.template;
for (let i = 0; i < compiledFiles.length; i++) {
const file = compiledFiles[i];
const finalDest = file.dest(dest, data, defs);
// eslint-disable-next-line no-await-in-loop
if (await isFileAsync(finalDest)) {
throw new FileExistError(finalDest);
}
}
}
/**
* Render the build path
*/
public async render(
answers: AnswersHash = {},
data: RenderData = {},
): Promise<void> {
const realBuildPath = this.getDirectory();
const loggerGroup = this.getLogger();
const doesBuildPathExist = await this.directoryExists();
/**
* @example
* if
* cwd: '/User/home/app'
* build path: 'test' // short build path
* new folder: true
* then
* realBuildPath: '/User/home/app/test'
* - A new directory named `test` needs to be created
*
* @example
* if
* cwd: '/User/home/app'
* build path: 'test/test2' // long build path
* new folder: true
* then
* realBuildPath: '/User/home/app/test/test2'
* - A new directory named `test` needs to be created if doesn't exist already, `test2` should be created regardless
*
* @example
* if
* cwd: '/User/home/app'
* build path: '' // build in dest
* new folder: true??
* then
* realBuildPath: '/User/home/app'
* - this directory should not be created or overridden since it should exist.
*
* @example
* if
* cwd: '/User/home/app'
* build path: 'test' // short build path
* new folder: false
* then
* realBuildPath: '/User/home/app'
* - this directory should not be created or overridden since it should exist.
*
* @example
* if
* cwd: '/User/home/app'
* build path: 'test/test2' // short build path
* new folder: false
* then
* realBuildPath: '/User/home/app'
* - A directory named `test` needs to be created if not already exists
*
*/
const renderData = {
...data,
packages: this.template.packagesUsed,
template: this.template.name,
answers,
a: answers,
utils,
u: utils,
name: this.name,
dir: this.directory,
};
const marker = colors.magenta('*'.repeat(this.buildPath.length + 12));
loggerGroup.info(`\n${marker}\nBuild Path: ${this.buildPath}\n${marker}`);
loggerGroup.info('Render config: %n', {
name: renderData.name,
buildPath: this.buildPath,
'Final Destination': realBuildPath,
doesBuildPathExist,
buildInDest: this.options.buildInDest,
buildNewFolder: this.options.buildNewFolder,
});
const wasWiped = await this.maybeWipe(() => {
// super hacky yes i know. The reason this needs to happen is because
// when were using wipe but were not building a new folder we need to make sure all
// files that already exist get overridden
this.template.compiledFiles.forEach((file) => {
// eslint-disable-next-line no-param-reassign
file.options.force = true;
});
});
loggerGroup.info('Build was wiped', wasWiped);
/**
* when wipe=true but buildNewFolder=false we need to act like `force` and not
* check for files.
*/
const shouldWipeButNoNewFolder =
this.options.wipe && !this.options.buildNewFolder;
/**
* Check for file conflicts when:
* - folder was not wiped
* - force option is not true
* - when wipe but no new folder
*/
if (!wasWiped && !this.options.force && !shouldWipeButNoNewFolder) {
loggerGroup.info('Checking to see if there are duplicate files');
await this.checkForConflicts(realBuildPath, renderData);
}
// Create a new folder unless told not to
// if we are building the template in dest folder don't create new folder
if (
!this.options.buildInDest &&
(this.options.buildNewFolder || !(await this.directoryExists()))
) {
loggerGroup.info('Creating real build path %s', realBuildPath);
await this.createDirectory().catch((err) => {
loggerGroup.warn('Building build path folder had a issue %n', err);
});
} else {
loggerGroup.info('Not creating real build path %s', realBuildPath);
}
await this.renderDirectories();
await this.renderFiles(renderData);
loggerGroup.success(
`Build Path: %s ${colors.green.italic('(created)')}`,
this.buildPath,
);
}
/**
* Creates all directories our instance needs. This will use all
* directories in any package that was loaded.
*/
private async renderDirectories() {
const dirTracker: Record<string, boolean> = {};
const directory = this.getDirectory();
const loggerGroup = this.getLogger();
loggerGroup.info('Rendering directories in %s', directory);
const dirsInProgress = this.template
.usedPackages()
.map(async (pkg): Promise<void> => {
const dirs = pkg.find({ type: 'dir' });
const dirsGettingCreated = dirs.map(
async (dirNode: DirectoryNode): Promise<void> => {
/* skip if directory has already been made */
if (dirNode.path in dirTracker) return;
const dirPathRelativeFromPkg = dirNode.getRelativePathFrom(
pkg,
false,
);
const dirPathInNewLocation = path.join(
directory,
dirPathRelativeFromPkg,
);
dirTracker[dirNode.path] = true;
if (await isDirAsync(dirPathInNewLocation)) {
return;
}
try {
await fs.mkdir(dirPathInNewLocation, {
recursive: true,
});
this.built.directories.push(dirPathInNewLocation);
loggerGroup.info(
` - %s ${colors.green.italic('(created)')}`,
dirPathRelativeFromPkg,
);
} catch (err) {
/* do nothing if dir already exist */
loggerGroup.warn(
` - %s ${colors.red.italic('failed')} %n`,
dirPathRelativeFromPkg,
err,
);
return Promise.reject(err);
}
},
);
await Promise.all(dirsGettingCreated);
});
await Promise.all(dirsInProgress);
loggerGroup.info(
'Extra directories that need to be created %n',
this.template.extraDirectories,
);
loggerGroup.info('Creating extra directories:');
/**
* Create all extra directories
*/
await Promise.all(
this.template.extraDirectories.map(async (dir) => {
const newDir = path.join(directory, dir);
try {
await fs.mkdir(newDir, { recursive: true });
this.built.directories.push(newDir);
loggerGroup.info(
` - %s ${colors.green.italic('(created)')}`,
newDir,
);
} catch (e) {
loggerGroup.info(` - %s ${colors.red.italic('(Failed)')}`, newDir);
throw e;
}
}),
);
loggerGroup.info('All directories have been created');
}
/**
* Creates all files that our template uses in `buildPath` folder
* @param {Object} [data={}] - data passed in for dot
*/
private async renderFiles(data: RenderData): Promise<void> {
const loggerGroup = this.getLogger();
const location = this.getDirectory();
loggerGroup.info('Rendering files');
const results = await Promise.allSettled(
this.template.compiledFiles.map(async (file) => {
const type = file.isDynamic ? 'Dynamic File' : 'File';
const dest = file.dest(this.buildPath, data, this.template.defs);
let failed = false;
try {
await file.render(location, data, this.template.defs);
this.built.files.push(dest);
} catch (error) {
failed = true;
throw error;
} finally {
const status = failed
? colors.red('Failed')
: colors.green('Created');
loggerGroup.info(
` - %s ${colors.cyan.italic(`(${type})`)} (${status})`,
file.dest(this.buildPath, data, this.template.defs),
);
}
}),
);
const errors: Error[] = results
.filter((result) => result.status === 'rejected')
.map((result) => result.reason);
if (errors.length) {
loggerGroup.error('Build path failed %s', this.buildPath);
throw new BuildError(this.buildPath, errors);
}
}
/**
* Delete everything that was created in this build. This will run if any file or directory
* error when being created. We dont want to leave broken templates created
* so this function will delete everything that this template built
*/
public async clean(buildNewFolder: boolean): Promise<void> {
let buildPath = this.getDirectory();
logger.tps.info('Processing build cleanup %s %o', buildPath, {
buildNewFolder,
});
const buildPathNeedsSlash = buildPath[buildPath.length - 1] === path.sep;
if (!buildPathNeedsSlash) {
buildPath += path.sep;
}
if (buildNewFolder) {
await fs.rm(buildPath, { force: true, recursive: true });
}
// eslint-disable-next-line prefer-const
let { directories, files } = this.built;
const filesIsEmpty: boolean = !files.length;
const dirsIsEmpty: boolean = !directories.length;
if (filesIsEmpty && dirsIsEmpty) {
logger.tps.success('Nothing to clean... Moving on to next');
return;
}
if (!dirsIsEmpty) {
const dirsThatMatch = directories.filter((dir) =>
dir.includes(buildPath),
);
if (dirsThatMatch.length) {
logger.tps.info('Cleaning directories %n', dirsThatMatch);
}
for (let i = 0; i < dirsThatMatch.length; i++) {
const dir = dirsThatMatch[i];
try {
// eslint-disable-next-line no-await-in-loop
await fs.rm(dir, { force: true, recursive: true });
logger.tps.success(` - %s ${colors.green.italic('(deleted)')}`, dir);
} catch (err) {
logger.tps.error('Clean up failed when deleting directories %n', err);
}
// if directory is removed then we can remove all child files
if (!filesIsEmpty) {
files = files.filter((file) => !file.includes(dir));
}
}
}
if (!filesIsEmpty) {
const filesThatMatch = files.filter((file) => file.includes(buildPath));
if (filesThatMatch.length) {
logger.tps.info('Cleaning files %n', filesThatMatch);
}
await Promise.all(
files.map(async (file) => {
try {
await fs.rm(file, { force: true });
logger.tps.success(
` - %s ${colors.green.italic('(deleted)')}`,
file,
);
} catch (err) {
logger.tps.error('Clean up failed when deleting files %n', err);
}
}),
);
}
logger.tps.success('Clean up finished');
}
}