diff --git a/.vscode/settings.json b/.vscode/settings.json index 8b3c96c..e51a606 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -3,10 +3,11 @@ "npm.packageManager": "yarn", "cSpell.words": [ "massarg", - "nodir", "nobrace", - "noext", "nocomment", - "nonegate" + "nodir", + "noext", + "nonegate", + "subdir" ] } diff --git a/README.md b/README.md index 2f901fc..2a6809c 100644 --- a/README.md +++ b/README.md @@ -36,40 +36,45 @@ Create structured files based on templates. Options: - --help|-h Display help information + --help|-h Display help information - --name|-n Name to be passed to the generated files. {{name}} and - {{Name}} inside contents and file names will be replaced - accordingly. + --name|-n Name to be passed to the generated files. {{name}} and + {{Name}} inside contents and file names will be replaced + accordingly. - --output|-o Path to output to. If --create-sub-folder is enabled, the - subfolder will be created inside this path. + --output|-o Path to output to. If --create-sub-folder is enabled, + the subfolder will be created inside this path. + (default: current dir) - --templates|-t Template files to use as input. You may provide multiple - files, each of which can be a relative or absolute path, or a glob - pattern for multiple file matching easily. (default: current dir) + --templates|-t Template files to use as input. You may provide multiple + files, each of which can be a relative or absolute path, or a + glob pattern for multiple file matching easily. - --overwrite|-w Enable to override output files, even if they already exist. - (default: false) + --overwrite|-w Enable to override output files, even if they already + exist. (default: false) - --data|-d Add custom data to the templates. By default, only your app - name is included. + --data|-d Add custom data to the templates. By default, only your + app name is included. - --create-sub-folder|-s Create subfolder with the input name (default: - false) + --create-sub-folder|-s Create subfolder with the input name + (default: false) - --quiet|-q Suppress output logs (Same as --verbose 0) - (default: false) + --sub-folder-name-helper|-sh Default helper to apply to subfolder name when using + `--create-sub-folder true`. - --verbose|-v Determine amount of logs to display. The values are: 0 - (none) | 1 (debug) | 2 (info) | 3 (warn) | 4 (error). The - provided level will display messages of the same level or higher. - (default: 2) + --quiet|-q Suppress output logs (Same as --verbose 0) + (default: false) - --dry-run|-dr Don't emit files. This is good for testing your scaffolds and - making sure they don't fail, without having to write actual file - contents or create directories. (default: - false) + --verbose|-v Determine amount of logs to display. The values are: + 0 (none) | 1 (debug) | 2 (info) | 3 (warn) | 4 + (error). The provided level will display messages of + the same level or higher. (default: + 2) + + --dry-run|-dr Don't emit files. This is good for testing your + scaffolds and making sure they don't fail, without having to + write actual file contents or create directories. + (default: false) ``` You can also add this as a script in your `package.json`: @@ -98,6 +103,7 @@ const config = { templates: [path.join(__dirname, "scaffolds", "component")], output: path.join(__dirname, "src", "components"), createSubFolder: true, + subFolderNameHelper: "upperCase" locals: { property: "value", }, @@ -184,6 +190,9 @@ config.helpers = { } ``` +These helpers will also be available to you when using `subFolderNameHelper` or +`--sub-folder-name-helper` as a possible value. + ## Examples ### Command Example diff --git a/package.json b/package.json index c5d3bc1..6d80c1a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "simple-scaffold", - "version": "1.0.0", + "version": "1.0.1", "description": "Create files based on templates", "repository": "https://github.com/chenasraf/simple-scaffold.git", "author": "Chen Asraf ", diff --git a/src/cmd.ts b/src/cmd.ts index 10562c0..ad922e5 100644 --- a/src/cmd.ts +++ b/src/cmd.ts @@ -51,6 +51,11 @@ export function parseCliArgs(args = process.argv.slice(2)) { defaultValue: false, description: "Create subfolder with the input name", }) + .option({ + name: "sub-folder-name-helper", + aliases: ["sh"], + description: "Default helper to apply to subfolder name when using `--create-sub-folder true`.", + }) .option({ name: "quiet", aliases: ["q"], diff --git a/src/scaffold.ts b/src/scaffold.ts index 0da92ec..dd64f78 100644 --- a/src/scaffold.ts +++ b/src/scaffold.ts @@ -16,79 +16,81 @@ import { removeGlob, makeRelativePath, registerHelpers, + getTemplateGlobInfo, + ensureFileExists, + getFileList, + getBasePath, + copyFileTransformed, + getTemplateFileInfo, + logInitStep, + logInputFile, + GlobInfo, + OutputFileInfo, } from "./utils" -import { LogLevel, ScaffoldConfig } from "./types" +import { FileResponse, LogLevel, ScaffoldConfig } from "./types" +/** + * Create a scaffold using given `options`. + * + * #### Create files + * To create a file structure to output, use any directory and file structure you would like. + * Inside folder names, file names or file contents, you may place `{{ var }}` where `var` is either + * `name` which is the scaffold name you provided or one of the keys you provided in the `data` option. + * + * The contents and names will be replaced with the transformed values so you can use your original structure as a + * boilerplate for other projects, components, modules, or even single files. + * + * #### Helpers + * Helpers are functions you can use to transform your `{{ var }}` contents into other values without having to + * pre-define the data and use a duplicated key. Common cases are transforming name-case format + * (e.g. `MyName` → `my_name`), so these have been provided as defaults: + * + * | Helper name | Example code | Example output | + * | ----------- | ----------------------- | -------------- | + * | camelCase | `{{ camelCase name }}` | myName | + * | snakeCase | `{{ snakeCase name }}` | my_name | + * | startCase | `{{ startCase name }}` | My Name | + * | kebabCase | `{{ kebabCase name }}` | my-name | + * | hyphenCase | `{{ hyphenCase name }}` | my-name | + * | pascalCase | `{{ pascalCase name }}` | MyName | + * | upperCase | `{{ upperCase name }}` | MYNAME | + * | lowerCase | `{{ lowerCase name }}` | myname | + * + * Any functions you provide in `helpers` option will also be available to you to make custom formatting as you see fit + * (for example, formatting a date) + */ export async function Scaffold({ ...options }: ScaffoldConfig) { options.output ??= process.cwd() registerHelpers(options) try { options.data = { name: options.name, Name: pascalCase(options.name), ...options.data } - log(options, LogLevel.Debug, "Full config:", { - name: options.name, - templates: options.templates, - output: options.output, - createSubfolder: options.createSubFolder, - data: options.data, - overwrite: options.overwrite, - quiet: options.quiet, - helpers: Object.keys(options.helpers ?? {}), - verbose: `${options.verbose} (${Object.keys(LogLevel).find( - (k) => (LogLevel[k as any] as unknown as number) === options.verbose! - )})`, - }) - log(options, LogLevel.Info, "Data:", options.data) - for (let template of options.templates) { + logInitStep(options) + for (let _template of options.templates) { try { - const _isGlob = glob.hasMagic(template) - if (!_isGlob && !(await pathExists(template))) { - const err: NodeJS.ErrnoException = new Error(`ENOENT, no such file or directory ${template}`) - err.code = "ENOENT" - err.path = "non-existing-input" - err.errno = -2 - throw err - } - const _nonGlobTemplate = _isGlob ? removeGlob(template) : template - log(options, LogLevel.Debug, "before isDir", "isGlob:", _isGlob, template) - const _isDir = _isGlob ? true : await isDir(template) - log(options, LogLevel.Debug, "after isDir", _isDir) - const _shouldAddGlob = !_isGlob && _isDir - const origTemplate = template - if (_shouldAddGlob) { - template = template + "/**/*" - } - log(options, LogLevel.Debug, "before glob") - const files = await promisify(glob)(template, { - dot: true, - debug: options.verbose === LogLevel.Debug, - nodir: true, - }) - log(options, LogLevel.Debug, "after glob") + const { nonGlobTemplate, origTemplate, isDirOrGlob, isGlob, template } = await getTemplateGlobInfo( + options, + _template + ) + await ensureFileExists(template, isDirOrGlob) + const files = await getFileList(options, template) for (const inputFilePath of files) { if (await isDir(inputFilePath)) { continue } - const relPath = makeRelativePath(path.dirname(removeGlob(inputFilePath).replace(_nonGlobTemplate, ""))) - const basePath = path - .resolve(process.cwd(), relPath) - .replace(process.cwd() + "/", "") - .replace(process.cwd(), "") - log( - options, - LogLevel.Debug, - `\nprocess.cwd(): ${process.cwd()}`, - `\norigTemplate: ${origTemplate}`, - `\nrelPath: ${relPath}`, - `\ntemplate: ${template}`, - `\ninputFilePath: ${inputFilePath}`, - `\nnonGlobTemplate: ${_nonGlobTemplate}`, - `\nbasePath: ${basePath}`, - `\nisDir: ${_isDir}`, - `\nisGlob: ${_isGlob}`, - `\n` - ) - await handleTemplateFile(inputFilePath, basePath, options, options.data) + const relPath = makeRelativePath(path.dirname(removeGlob(inputFilePath).replace(nonGlobTemplate, ""))) + const basePath = getBasePath(relPath) + logInputFile(options, { + origTemplate, + relPath, + template, + inputFilePath, + nonGlobTemplate, + basePath, + isDirOrGlob, + isGlob, + }) + await handleTemplateFile(options, options.data, { templatePath: inputFilePath, basePath }) } } catch (e: any) { handleErr(e) @@ -99,22 +101,19 @@ export async function Scaffold({ ...options }: ScaffoldConfig) { throw e } } - async function handleTemplateFile( - templatePath: string, - basePath: string, options: ScaffoldConfig, - data: Record + data: Record, + { templatePath, basePath }: { templatePath: string; basePath: string } ): Promise { return new Promise(async (resolve, reject) => { try { - const inputPath = path.resolve(process.cwd(), templatePath) - const outputPathOpt = getOptionValueForFile(options, inputPath, data, options.output) - const outputDir = path.resolve( - process.cwd(), - ...([outputPathOpt, basePath, options.createSubFolder ? options.name : undefined].filter(Boolean) as string[]) - ) - const outputPath = handlebarsParse(options, path.join(outputDir, path.basename(inputPath)), data).toString() + const { inputPath, outputPathOpt, outputDir, outputPath, exists } = await getTemplateFileInfo(options, data, { + templatePath, + basePath, + }) + const overwrite = getOptionValueForFile(options, inputPath, data, options.overwrite ?? false) + log( options, LogLevel.Debug, @@ -126,29 +125,11 @@ async function handleTemplateFile( `\nFull output path: ${outputPath}`, `\n` ) - const overwrite = getOptionValueForFile(options, inputPath, data, options.overwrite ?? false) - const exists = await pathExists(outputPath) await createDirIfNotExists(path.dirname(outputPath), options) log(options, LogLevel.Info, `Writing to ${outputPath}`) - if (!exists || overwrite) { - if (exists && overwrite) { - log(options, LogLevel.Info, `File ${outputPath} exists, overwriting`) - } - const templateBuffer = await readFile(inputPath) - const outputContents = handlebarsParse(options, templateBuffer, data) - - if (!options.dryRun) { - await writeFile(outputPath, outputContents) - log(options, LogLevel.Info, "Done.") - } else { - log(options, LogLevel.Info, "Content output:") - log(options, LogLevel.Info, outputContents) - } - } else if (exists) { - log(options, LogLevel.Info, `File ${outputPath} already exists, skipping`) - } + await copyFileTransformed(options, data, { exists, overwrite, outputPath, inputPath }) resolve() } catch (e: any) { handleErr(e) diff --git a/src/types.ts b/src/types.ts index e6012c7..e5b6ed5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -10,6 +10,20 @@ export type FileResponseFn = (fullPath: string, basedir: string, basename: st export type FileResponse = T | FileResponseFn +export type DefaultHelperKeys = + | "camelCase" + | "snakeCase" + | "startCase" + | "kebabCase" + | "hyphenCase" + | "pascalCase" + | "lowerCase" + | "upperCase" + +export type HelperKeys = DefaultHelperKeys | T + +export type Helper = (text: string) => string + export interface ScaffoldConfig { /** * Name to be passed to the generated files. `{{name}}` and `{{Name}}` inside contents and file names will be replaced @@ -79,8 +93,26 @@ export interface ScaffoldConfig { * } * }) * ``` + * + * Here are the built-in helpers available for use: + * | Helper name | Example code | Example output | + * | ----------- | ----------------------- | -------------- | + * | camelCase | `{{ camelCase name }}` | myName | + * | snakeCase | `{{ snakeCase name }}` | my_name | + * | startCase | `{{ startCase name }}` | My Name | + * | kebabCase | `{{ kebabCase name }}` | my-name | + * | hyphenCase | `{{ hyphenCase name }}` | my-name | + * | pascalCase | `{{ pascalCase name }}` | MyName | + * | upperCase | `{{ upperCase name }}` | MYNAME | + * | lowerCase | `{{ lowerCase name }}` | myname | */ - helpers?: Record string> + helpers?: Record + + /** + * Default transformer to apply to subfolder name when using `createSubFolder: true`. Can be one of the default + * helpers, or a custom one you provide to `helpers`. Defaults to `undefined`, which means no transformation is done. + */ + subFolderNameHelper?: DefaultHelperKeys | string } export interface ScaffoldCmdConfig { name: string diff --git a/src/utils.ts b/src/utils.ts index c421303..0565d20 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,6 +1,6 @@ import path from "path" import { F_OK } from "constants" -import { FileResponse, FileResponseFn, LogLevel, ScaffoldConfig } from "./types" +import { DefaultHelperKeys, FileResponse, FileResponseFn, Helper, LogLevel, ScaffoldConfig } from "./types" import camelCase from "lodash/camelCase" import snakeCase from "lodash/snakeCase" import kebabCase from "lodash/kebabCase" @@ -10,7 +10,11 @@ import { promises as fsPromises } from "fs" import chalk from "chalk" const { stat, access, mkdir } = fsPromises -export const defaultHelpers: Exclude = { +import { glob } from "glob" +import { promisify } from "util" +const { readFile, writeFile } = fsPromises + +export const defaultHelpers: Record = { camelCase, snakeCase, startCase, @@ -34,7 +38,7 @@ export function handleErr(err: NodeJS.ErrnoException | null) { } export function log(options: ScaffoldConfig, level: LogLevel, ...obj: any[]) { - if (options.quiet || options.verbose === LogLevel.None || level <= (options.verbose ?? LogLevel.Info)) { + if (options.quiet || options.verbose === LogLevel.None || level < (options.verbose ?? LogLevel.Info)) { return } const levelColor: Record = { @@ -139,3 +143,177 @@ export function removeGlob(template: string) { export function makeRelativePath(str: string): string { return str.startsWith("/") ? str.slice(1) : str } + +export function getBasePath(relPath: string) { + return path + .resolve(process.cwd(), relPath) + .replace(process.cwd() + "/", "") + .replace(process.cwd(), "") +} + +export async function getFileList(options: ScaffoldConfig, template: string) { + return await promisify(glob)(template, { + dot: true, + debug: options.verbose === LogLevel.Debug, + nodir: true, + }) +} + +export interface GlobInfo { + nonGlobTemplate: string + origTemplate: string + isDirOrGlob: boolean + isGlob: boolean + template: string +} + +export async function getTemplateGlobInfo(options: ScaffoldConfig, template: string): Promise { + const isGlob = glob.hasMagic(template) + log(options, LogLevel.Debug, "before isDir", "isGlob:", isGlob, template) + let _template = template + const nonGlobTemplate = isGlob ? removeGlob(template) : template + const isDirOrGlob = isGlob ? true : await isDir(template) + log(options, LogLevel.Debug, "after isDir", isDirOrGlob) + const _shouldAddGlob = !isGlob && isDirOrGlob + const origTemplate = template + if (_shouldAddGlob) { + _template = template + "/**/*" + } + return { nonGlobTemplate, origTemplate, isDirOrGlob, isGlob, template: _template } +} + +export async function ensureFileExists(template: string, isGlob: boolean) { + if (!isGlob && !(await pathExists(template))) { + const err: NodeJS.ErrnoException = new Error(`ENOENT, no such file or directory ${template}`) + err.code = "ENOENT" + err.path = template + err.errno = -2 + throw err + } +} + +export interface OutputFileInfo { + inputPath: string + outputPathOpt: string + outputDir: string + outputPath: string + exists: boolean +} + +export async function getTemplateFileInfo( + options: ScaffoldConfig, + data: Record, + { templatePath, basePath }: { templatePath: string; basePath: string } +): Promise { + const inputPath = path.resolve(process.cwd(), templatePath) + const outputPathOpt = getOptionValueForFile(options, inputPath, data, options.output) + const outputDir = getOutputDir(options, data, outputPathOpt, basePath) + const outputPath = handlebarsParse(options, path.join(outputDir, path.basename(inputPath)), data).toString() + const exists = await pathExists(outputPath) + return { inputPath, outputPathOpt, outputDir, outputPath, exists } +} + +export async function copyFileTransformed( + options: ScaffoldConfig, + data: Record, + { + exists, + overwrite, + outputPath, + inputPath, + }: { exists: boolean; overwrite: boolean; outputPath: string; inputPath: string } +) { + if (!exists || overwrite) { + if (exists && overwrite) { + log(options, LogLevel.Info, `File ${outputPath} exists, overwriting`) + } + const templateBuffer = await readFile(inputPath) + const outputContents = handlebarsParse(options, templateBuffer, data) + + if (!options.dryRun) { + await writeFile(outputPath, outputContents) + log(options, LogLevel.Info, "Done.") + } else { + log(options, LogLevel.Info, "Content output:") + log(options, LogLevel.Info, outputContents) + } + } else if (exists) { + log(options, LogLevel.Info, `File ${outputPath} already exists, skipping`) + } +} + +export function getOutputDir( + options: ScaffoldConfig, + data: Record, + outputPathOpt: string, + basePath: string +) { + return path.resolve( + process.cwd(), + ...([ + outputPathOpt, + basePath, + options.createSubFolder + ? options.subFolderNameHelper + ? handlebarsParse(options, `{{ ${options.subFolderNameHelper} name }}`, data) + : options.name + : undefined, + ].filter(Boolean) as string[]) + ) +} + +export function logInputFile( + options: ScaffoldConfig, + { + origTemplate, + relPath, + template, + inputFilePath, + nonGlobTemplate, + basePath, + isDirOrGlob, + isGlob, + }: { + origTemplate: string + relPath: string + template: string + inputFilePath: string + nonGlobTemplate: string + basePath: string + isDirOrGlob: boolean + isGlob: boolean + } +) { + log( + options, + LogLevel.Debug, + `\nprocess.cwd(): ${process.cwd()}`, + `\norigTemplate: ${origTemplate}`, + `\nrelPath: ${relPath}`, + `\ntemplate: ${template}`, + `\ninputFilePath: ${inputFilePath}`, + `\nnonGlobTemplate: ${nonGlobTemplate}`, + `\nbasePath: ${basePath}`, + `\nisDirOrGlob: ${isDirOrGlob}`, + `\nisGlob: ${isGlob}`, + `\n` + ) +} + +export function logInitStep(options: ScaffoldConfig) { + log(options, LogLevel.Debug, "Full config:", { + name: options.name, + templates: options.templates, + output: options.output, + createSubfolder: options.createSubFolder, + data: options.data, + overwrite: options.overwrite, + quiet: options.quiet, + subFolderTransformHelper: options.subFolderNameHelper, + helpers: Object.keys(options.helpers ?? {}), + verbose: `${options.verbose} (${Object.keys(LogLevel).find( + (k) => (LogLevel[k as any] as unknown as number) === options.verbose! + )})`, + }) + log(options, LogLevel.Info, "Data:", options.data) +} diff --git a/tests/scaffold.test.ts b/tests/scaffold.test.ts index a2822ca..ed5fe4a 100644 --- a/tests/scaffold.test.ts +++ b/tests/scaffold.test.ts @@ -31,6 +31,12 @@ const fileStructNested = { }, output: {}, } +const fileStructSubdirTransformer = { + input: { + "{{name}}.txt": "Hello, my app is {{name}}", + }, + output: {}, +} const defaultHelperNames = Object.keys(defaultHelpers) const fileStructHelpers = { @@ -272,4 +278,52 @@ describe("Scaffold", () => { }) }) ) + describe( + "transform subfolder", + withMock(fileStructSubdirTransformer, () => { + test("should work with no helper", async () => { + await Scaffold({ + name: "app_name", + output: "output", + templates: ["input"], + createSubFolder: true, + verbose: 0, + }) + + const data = readFileSync(process.cwd() + "/output/app_name/app_name.txt") + expect(data.toString()).toBe("Hello, my app is app_name") + }) + + test("should work with default helper", async () => { + await Scaffold({ + name: "app_name", + output: "output", + templates: ["input"], + createSubFolder: true, + verbose: 0, + subFolderNameHelper: "upperCase", + }) + + const data = readFileSync(process.cwd() + "/output/APP_NAME/app_name.txt") + expect(data.toString()).toBe("Hello, my app is app_name") + }) + + test("should work with custom helper", async () => { + await Scaffold({ + name: "app_name", + output: "output", + templates: ["input"], + createSubFolder: true, + verbose: 0, + subFolderNameHelper: "test", + helpers: { + test: () => "REPLACED", + }, + }) + + const data = readFileSync(process.cwd() + "/output/REPLACED/app_name.txt") + expect(data.toString()).toBe("Hello, my app is app_name") + }) + }) + ) })