diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..974d120 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,37 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: bug, needs-triage +assignees: chenasraf + +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior: + +1. Prepare templates: + ``` + template contents + ``` +2. Run with args/config: + ``` + npx simple-scaffold@latest -t ... -o output TplName + ``` +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Logs** +If applicable, paste your logs to help explain your problem. +To see more logs, run the scaffold with `-v 1` to enable debug logging. + +**Desktop (please complete the following information):** + - OS: [e.g. iOS] +- Node.js: [e.g. 16.8] + - Simple Scaffold Version [e.g. 1.0.4] + +**Additional context** +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..fbd6563 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,20 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: '' +labels: enhancement, needs-triage +assignees: chenasraf + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.github/workflows/alpha.yml b/.github/workflows/develop.yml similarity index 98% rename from .github/workflows/alpha.yml rename to .github/workflows/develop.yml index f513871..1a28d2e 100644 --- a/.github/workflows/alpha.yml +++ b/.github/workflows/develop.yml @@ -2,7 +2,7 @@ name: Alpha Releases on: push: - branches: [alpha] + branches: [develop] jobs: build: runs-on: ubuntu-latest diff --git a/.github/workflows/pull_requests.yml b/.github/workflows/pull_requests.yml index c1cd80c..ef30547 100644 --- a/.github/workflows/pull_requests.yml +++ b/.github/workflows/pull_requests.yml @@ -2,7 +2,7 @@ name: Pull Requests on: pull_request: - branches: [master, alpha, beta] + branches: [master, develop] jobs: build: runs-on: ubuntu-latest diff --git a/.github/workflows/main.yml b/.github/workflows/release.yml similarity index 99% rename from .github/workflows/main.yml rename to .github/workflows/release.yml index af46731..e29cae5 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/release.yml @@ -1,4 +1,4 @@ -name: Releases +name: Release on: push: diff --git a/.vscode/settings.json b/.vscode/settings.json index e51a606..3fd94e3 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -3,6 +3,7 @@ "npm.packageManager": "yarn", "cSpell.words": [ "massarg", + "myname", "nobrace", "nocomment", "nodir", diff --git a/README.md b/README.md index 04b3c5c..cd4c055 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# simple-scaffold +# Simple Scaffold Simple Scaffold allows you to generate any set of files in the easiest way possible with simple commands. @@ -9,6 +9,38 @@ Simply organize your commonly-created files in their original structure, and run will copy the files to the output path, while replacing values (such as component or app name, or other custom data) inside the paths or contents of the files using Handlebars.js syntax. +
+ +
+ Table of contents + +- [Simple Scaffold](#simple-scaffold) + - [Install](#install) + - [Use as a command line tool](#use-as-a-command-line-tool) + - [Command Line Options](#command-line-options) + - [Use in Node.js](#use-in-nodejs) + - [Node-specific options](#node-specific-options) + - [Preparing files](#preparing-files) + - [Template files](#template-files) + - [Variable/token replacement](#variabletoken-replacement) + - [Built-in Helpers](#built-in-helpers) + - [Capitalization Helpers](#capitalization-helpers) + - [Date helpers](#date-helpers) + - [Custom Helpers](#custom-helpers) + - [Examples](#examples) + - [Command Example](#command-example) + - [Example Scaffold Input](#example-scaffold-input) + - [Input Directory structure](#input-directory-structure) + - [Contents of `project/scaffold/{{Name}}.jsx`](#contents-of-projectscaffoldnamejsx) + - [Example Scaffold Output](#example-scaffold-output) + - [Output directory structure](#output-directory-structure) + - [Contents of `project/scaffold/MyComponent/MyComponent.jsx`](#contents-of-projectscaffoldmycomponentmycomponentjsx) + - [Contributing](#contributing) + +
+ +--- + ## Install You can either use it as a command line tool or import into your own code and run from there. @@ -86,8 +118,9 @@ You can also add this as a script in your `package.json`: ## Use in Node.js -You can also build the scaffold yourself, if you want to create more complex arguments or scaffold groups. -Simply pass a config object to the Scaffold function when you are ready to start. +You can also build the scaffold yourself, if you want to create more complex arguments or scaffold +groups - simply pass a config object to the Scaffold function when you are ready to start. + The config takes similar arguments to the command line: ```typescript @@ -104,30 +137,23 @@ const config = { }, helpers: { twice: (text) => [text, text].join(" ") - } + }, + beforeWrite: (content, rawContent, outputPath) => content.toString().toUpperCase() } const scaffold = Scaffold(config) ``` -### Additional Node.js options +### Node-specific options -In addition to all the options available in the command line, there are some JS-specific options -available: +In addition to all the options available in the command line, there are some Node/JS-specific +options available: -1. When `output` is used in Node directly, it may also be passed a function for each input file to - output into a dynamic path: - - ```typescript - config.output = (fullPath, baseDir, baseName) => { - console.log({ fullPath, baseDir, baseName }) - return path.resolve(baseDir, baseName) - } - ``` - -2. You may add custom `helpers` to your scaffolds. Helpers are simple `(string) => string` functions - that transform your `data` variables into other values. See [Helpers](#helpers) for the list of - default helpers, or add your own to be loaded into the template parser. +| Option | Type | Description | +| ------------- | -------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `output` | | In addition to being passed the same as CLI, it may also be passed a function for each input file to output into a dynamic path: `{ output: (fullPath, baseDir, baseName) => path.resolve(baseDir, baseName) }` | +| `helpers` | `Record string>` | Helpers are simple functions that transform your `data` variables into other values. See [Helpers](#helpers) for the list of default helpers, or add your own to be loaded into the template parser. | +| `beforeWrite` | `(content: Buffer, rawContent: Buffer, outputPath: string) => Promise \| String \| Buffer \| undefined` | Supply this function to override the final output contents of each of your files, allowing you to add more pre-processing to your generator pipeline. The return value of this function will replace the output content of the respective file, which you may discriminate (if needed) using the `outputPath` argument. | ## Preparing files @@ -145,11 +171,11 @@ Examples: > In the following examples, the config `name` is `AppName`, and the config `output` is `src`. -| Input template | Output path(s) | -| ---------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------ | -| `./templates/{{ name }}.txt` | `src/AppName.txt` | -| `./templates/directory`

Directory contents:
  1. `outer/{{name}}.txt`
  2. `outer2/inner/{{name.txt}}`
| `src/outer/AppName.txt`,
`src/outer2/inner/AppName.txt` | -| `./templates/others/**/*.txt`

Directory contents:
  1. `outer/{{name}}.jpg`
  2. `outer2/inner/{{name.txt}}`
| `src/outer2/inner/AppName.txt` | +| Input template | Files in template | Output path(s) | +| ----------------------------- | ------------------------------------------------------ | ------------------------------------------------------------ | +| `./templates/{{ name }}.txt` | `./templates/{{ name }}.txt` | `src/AppName.txt` | +| `./templates/directory` | `outer/{{name}}.txt`,
`outer2/inner/{{name.txt}}` | `src/outer/AppName.txt`,
`src/outer2/inner/AppName.txt` | +| `./templates/others/**/*.txt` | `outer/{{name}}.jpg`,
`outer2/inner/{{name.txt}}` | `src/outer2/inner/AppName.txt` | ### Variable/token replacement @@ -165,7 +191,7 @@ For example, using the following command: npx simple-scaffold@latest \ --templates templates/components/{{name}}.jsx \ --output src/components \ - -create-sub-folder true \ + --create-sub-folder true \ MyComponent ``` @@ -188,28 +214,58 @@ Your `data` will be pre-populated with the following: > [Handlebars.js Language Features](https://handlebarsjs.com/guide/#language-features) for more > information. -#### Helpers +### Built-in Helpers Simple-Scaffold provides some built-in text transformation filters usable by handleBars. For example, you may use `{{ snakeCase name }}` inside a template file or filename, and it will replace `My Name` with `my_name` when producing the final value. -Here are the built-in helpers available for use: +#### Capitalization Helpers -| Helper name | Example code | Example output | -| ----------- | ----------------------- | -------------- | -| [None] | `{{ name }}` | my name | -| 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 }}` | MY NAME | -| lowerCase | `{{ lowerCase name }}` | my name | +| Helper name | Example code | Example output | +| ------------ | ----------------------- | -------------- | +| [None] | `{{ name }}` | my name | +| `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 }}` | MY NAME | +| `lowerCase` | `{{ lowerCase name }}` | my name | -> These helpers are available for any data property, not exclusive to `name`. +#### Date helpers + +| Helper name | Description | Example code | Example output | +| -------------------------------- | ---------------------------------------------------------------- | ---------------------------------------------------------------- | ------------------ | +| `now` | Current date with format | `{{ now "yyyy-MM-dd HH:mm" }}` | `2042-01-01 15:00` | +| `now` (with offset) | Current date with format, and with offset | `{{ now "yyyy-MM-dd HH:mm" -1 "hours" }}` | `2042-01-01 14:00` | +| `date` | Custom date with format | `{{ date "2042-01-01T15:00:00Z" "yyyy-MM-dd HH:mm" }}` | `2042-01-01 15:00` | +| `date` (with offset) | Custom date with format, and with offset | `{{ date "2042-01-01T15:00:00Z" "yyyy-MM-dd HH:mm" -1 "days" }}` | `2041-31-12 15:00` | +| `date` (with date from `--data`) | Custom date with format, with data from the `data` config option | `{{ date myCustomDate "yyyy-MM-dd HH:mm" }}` | `2042-01-01 12:00` | + +Further details: + +- We use [`date-fns`](https://date-fns.org/docs/) for parsing/manipulating the dates. + If you want more information on the date tokens to use, refer to + [their format documentation](https://date-fns.org/docs/format). + +- The date helper format takes the following arguments: + + ```typescript + ( + date: string, + format: string, + offsetAmount?: number, + offsetType?: "years" | "months" | "weeks" | "days" | "hours" | "minutes" | "seconds" + ) + ``` + +- **The now helper** (for current time) takes the same arguments, minus the first one (`date`) as + it is implicitly the current date. + +### Custom Helpers You may also add your own custom helpers using the `helpers` options when using the JS API (rather than the CLI). The `helpers` option takes an object whose keys are helper names, and values are @@ -221,8 +277,11 @@ config.helpers = { } ``` -These helpers will also be available to you when using `subFolderNameHelper` or -`--sub-folder-name-helper` as a possible value. +All of the above helpers (built in and custom) will also be available to you when using +`subFolderNameHelper` (`--sub-folder-name-helper`/`-sh`) as a possible value. + +> To see more information on how helpers work and more features, see +> [Handlebars.js docs](https://handlebarsjs.com/guide/#custom-helpers). ## Examples @@ -254,7 +313,7 @@ simple-scaffold MyComponent \ ```typescriptreact import React from 'react' -export default {{camelCase ame}}: React.FC = (props) => { +export default {{camelCase name}}: React.FC = (props) => { return (
{{camelCase name}} Component
) @@ -298,6 +357,16 @@ export default MyComponent: React.FC = (props) => { ## Contributing +I am developing this package on my free time, so any support, whether code, issues, or just stars +is very helpful to sustaining its life. If you would like to donate a bit to help keep the project +alive, I would be very thankful! + + + Buy Me a Coffee at ko-fi.com + + I welcome any issues or pull requests on GitHub. If you find a bug, or would like a new feature, don't hesitate to open an appropriate issue and I will do my best to reply promptly. diff --git a/package.json b/package.json index 4299ce8..e0da4d7 100644 --- a/package.json +++ b/package.json @@ -1,13 +1,23 @@ { "name": "simple-scaffold", - "version": "1.0.4", + "version": "1.1.0", "description": "Create files based on templates", "repository": "https://github.com/chenasraf/simple-scaffold.git", "author": "Chen Asraf ", "license": "MIT", "main": "index.js", "bin": "cmd.js", - "keywords": ["javascript", "cli", "template", "files", "typescript", "generator", "scaffold", "file", "scaffolding"], + "keywords": [ + "javascript", + "cli", + "template", + "files", + "typescript", + "generator", + "scaffold", + "file", + "scaffolding" + ], "scripts": { "clean": "rimraf dist/", "build": "yarn clean && tsc && chmod -R +x ./dist && cp ./package.json ./README.md ./dist/", @@ -20,6 +30,7 @@ }, "dependencies": { "chalk": "^4.1.2", + "date-fns": "^2.28.0", "glob": "^7.1.3", "handlebars": "^4.7.7", "lodash": "^4.17.21", @@ -27,7 +38,6 @@ "util.promisify": "^1.1.1" }, "devDependencies": { - "@types/args": "^3.0.1", "@types/glob": "^7.1.1", "@types/jest": "^26.0.24", "@types/lodash": "^4.14.171", diff --git a/src/scaffold.ts b/src/scaffold.ts index a91e023..b6af43d 100644 --- a/src/scaffold.ts +++ b/src/scaffold.ts @@ -11,7 +11,6 @@ import { makeRelativePath, registerHelpers, getTemplateGlobInfo, - ensureFileExists, getFileList, getBasePath, copyFileTransformed, @@ -51,7 +50,7 @@ import { LogLevel, ScaffoldConfig } from "./types" * 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) { +export async function Scaffold({ ...options }: ScaffoldConfig): Promise { options.output ??= process.cwd() registerHelpers(options) @@ -64,7 +63,6 @@ export async function Scaffold({ ...options }: ScaffoldConfig) { options, _template ) - await ensureFileExists(template, isDirOrGlob) const files = await getFileList(options, template) for (const inputFilePath of files) { if (await isDir(inputFilePath)) { @@ -82,7 +80,10 @@ export async function Scaffold({ ...options }: ScaffoldConfig) { isDirOrGlob, isGlob, }) - await handleTemplateFile(options, options.data, { templatePath: inputFilePath, basePath }) + await handleTemplateFile(options, options.data, { + templatePath: inputFilePath, + basePath, + }) } } catch (e: any) { handleErr(e) @@ -104,7 +105,7 @@ async function handleTemplateFile( templatePath, basePath, }) - const overwrite = getOptionValueForFile(options, inputPath, data, options.overwrite ?? false) + const overwrite = getOptionValueForFile(options, inputPath, options.overwrite ?? false) log( options, @@ -121,7 +122,7 @@ async function handleTemplateFile( await createDirIfNotExists(path.dirname(outputPath), options) log(options, LogLevel.Info, `Writing to ${outputPath}`) - await copyFileTransformed(options, data, { exists, overwrite, outputPath, inputPath }) + await copyFileTransformed(options, { exists, overwrite, outputPath, inputPath }) resolve() } catch (e: any) { handleErr(e) diff --git a/src/types.ts b/src/types.ts index e5b6ed5..9f9baf8 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,3 +1,5 @@ +import { HelperDelegate } from "handlebars/runtime" + export enum LogLevel { None = 0, Debug = 1, @@ -12,17 +14,19 @@ export type FileResponse = T | FileResponseFn export type DefaultHelperKeys = | "camelCase" + | "date" + | "hyphenCase" + | "kebabCase" + | "lowerCase" + | "now" + | "pascalCase" | "snakeCase" | "startCase" - | "kebabCase" - | "hyphenCase" - | "pascalCase" - | "lowerCase" | "upperCase" export type HelperKeys = DefaultHelperKeys | T -export type Helper = (text: string) => string +export type Helper = HelperDelegate export interface ScaffoldConfig { /** @@ -94,17 +98,7 @@ 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 | + * See the list of all the built-in available helpers, or how to define your own in the [readme](https://github.com/chenasraf/simple-scaffold#built-in-helpers). */ helpers?: Record @@ -113,6 +107,26 @@ export interface ScaffoldConfig { * helpers, or a custom one you provide to `helpers`. Defaults to `undefined`, which means no transformation is done. */ subFolderNameHelper?: DefaultHelperKeys | string + + /** + * This callback runs right before content is being written to the disk. If you supply this function, you may return + * a string that represents the final content of your file, you may process the content as you see fit. For example, + * you may run formatters on a file, fix output in edge-cases not supported by helpers or data, etc. + * + * If the return value of this function is `undefined`, the original content will be used. + * + * @param content The original template after token replacement + * @param rawContent The original template before token replacement + * @param outputPath The final output path of the processed file + * + * @returns `String | Buffer | undefined` The final output of the file contents-only, after further modifications - + * or `undefined` to use the original content (i.e. `content.toString()`) + */ + beforeWrite?( + content: Buffer, + rawContent: Buffer, + outputPath: string + ): string | Buffer | undefined | Promise } export interface ScaffoldCmdConfig { name: string diff --git a/src/utils.ts b/src/utils.ts index c6dc942..36a6c9a 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -9,6 +9,15 @@ import Handlebars from "handlebars" import { promises as fsPromises } from "fs" import chalk from "chalk" const { stat, access, mkdir } = fsPromises +import dtAdd from "date-fns/add" +import dtFormat from "date-fns/format" +import dtParseISO from "date-fns/parseISO" + +const dateFns = { + add: dtAdd, + format: dtFormat, + parseISO: dtParseISO, +} import { glob } from "glob" import { promisify } from "util" @@ -23,9 +32,53 @@ export const defaultHelpers: Record = { pascalCase, lowerCase: (text) => text.toLowerCase(), upperCase: (text) => text.toUpperCase(), + now: nowHelper, + date: dateHelper, } -export function registerHelpers(options: ScaffoldConfig) { +export function _dateHelper(date: Date, formatString: string): string +export function _dateHelper( + date: Date, + formatString: string, + durationDifference: number, + durationType: keyof Duration +): string +export function _dateHelper( + date: Date, + formatString: string, + durationDifference?: number, + durationType?: keyof Duration +): string { + if (durationType && durationDifference !== undefined) { + return dateFns.format(dateFns.add(date, { [durationType]: durationDifference }), formatString) + } + return dateFns.format(date, formatString) +} + +export function nowHelper(formatString: string): string +export function nowHelper(formatString: string, durationDifference: number, durationType: keyof Duration): string +export function nowHelper(formatString: string, durationDifference?: number, durationType?: keyof Duration): string { + return _dateHelper(new Date(), formatString, durationDifference!, durationType!) +} + +export function dateHelper(date: string, formatString: string): string +export function dateHelper( + date: string, + formatString: string, + durationDifference: number, + durationType: keyof Duration +): string + +export function dateHelper( + date: string, + formatString: string, + durationDifference?: number, + durationType?: keyof Duration +): string { + return _dateHelper(dateFns.parseISO(date), formatString, durationDifference!, durationType!) +} + +export function registerHelpers(options: ScaffoldConfig): void { const _helpers = { ...defaultHelpers, ...options.helpers } for (const helperName in _helpers) { log(options, LogLevel.Debug, `Registering helper: ${helperName}`) @@ -33,11 +86,11 @@ export function registerHelpers(options: ScaffoldConfig) { } } -export function handleErr(err: NodeJS.ErrnoException | null) { +export function handleErr(err: NodeJS.ErrnoException | null): void { if (err) throw err } -export function log(options: ScaffoldConfig, level: LogLevel, ...obj: any[]) { +export function log(options: ScaffoldConfig, level: LogLevel, ...obj: any[]): void { if (options.quiet || options.verbose === LogLevel.None || level < (options.verbose ?? LogLevel.Info)) { return } @@ -86,7 +139,6 @@ export async function createDirIfNotExists(dir: string, options: ScaffoldConfig) export function getOptionValueForFile( options: ScaffoldConfig, filePath: string, - data: Record, fn: FileResponse, defaultValue?: T ): T { @@ -104,7 +156,7 @@ export function handlebarsParse( options: ScaffoldConfig, templateBuffer: Buffer | string, { isPath = false }: { isPath?: boolean } = {} -) { +): Buffer { const { data } = options try { let str = templateBuffer.toString() @@ -116,11 +168,11 @@ export function handlebarsParse( if (isPath && path.sep !== "/") { outputContents = outputContents.replace(/\//g, "\\") } - return outputContents + return Buffer.from(outputContents) } catch (e) { log(options, LogLevel.Debug, e) log(options, LogLevel.Warning, "Couldn't parse file with handlebars, returning original content") - return templateBuffer + return Buffer.from(templateBuffer) } } @@ -145,7 +197,7 @@ export async function isDir(path: string): Promise { return tplStat.isDirectory() } -export function removeGlob(template: string) { +export function removeGlob(template: string): string { return template.replace(/\*/g, "").replace(/(\/\/|\\\\)/g, path.sep) } @@ -153,14 +205,14 @@ export function makeRelativePath(str: string): string { return str.startsWith(path.sep) ? str.slice(1) : str } -export function getBasePath(relPath: string) { +export function getBasePath(relPath: string): string { return path .resolve(process.cwd(), relPath) .replace(process.cwd() + path.sep, "") .replace(process.cwd(), "") } -export async function getFileList(options: ScaffoldConfig, template: string) { +export async function getFileList(options: ScaffoldConfig, template: string): Promise { return ( await promisify(glob)(template, { dot: true, @@ -193,16 +245,6 @@ export async function getTemplateGlobInfo(options: ScaffoldConfig, template: str 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 @@ -217,8 +259,8 @@ export async function getTemplateFileInfo( { 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 outputPathOpt = getOptionValueForFile(options, inputPath, options.output) + const outputDir = getOutputDir(options, outputPathOpt, basePath) const outputPath = handlebarsParse(options, path.join(outputDir, path.basename(inputPath)), { isPath: true, }).toString() @@ -228,39 +270,41 @@ export async function getTemplateFileInfo( export async function copyFileTransformed( options: ScaffoldConfig, - data: Record, { exists, overwrite, outputPath, inputPath, - }: { exists: boolean; overwrite: boolean; outputPath: string; inputPath: string } -) { + }: { + exists: boolean + overwrite: boolean + outputPath: string + inputPath: string + } +): Promise { if (!exists || overwrite) { if (exists && overwrite) { log(options, LogLevel.Info, `File ${outputPath} exists, overwriting`) } const templateBuffer = await readFile(inputPath) - const outputContents = handlebarsParse(options, templateBuffer) + const unprocessedOutputContents = handlebarsParse(options, templateBuffer) + const finalOutputContents = ( + (await options.beforeWrite?.(unprocessedOutputContents, templateBuffer, outputPath)) ?? unprocessedOutputContents + ).toString() if (!options.dryRun) { - await writeFile(outputPath, outputContents) + await writeFile(outputPath, finalOutputContents) log(options, LogLevel.Info, "Done.") } else { log(options, LogLevel.Info, "Content output:") - log(options, LogLevel.Info, outputContents) + log(options, LogLevel.Info, finalOutputContents) } } else if (exists) { log(options, LogLevel.Info, `File ${outputPath} already exists, skipping`) } } -export function getOutputDir( - options: ScaffoldConfig, - data: Record, - outputPathOpt: string, - basePath: string -) { +export function getOutputDir(options: ScaffoldConfig, outputPathOpt: string, basePath: string): string { return path.resolve( process.cwd(), ...([ @@ -268,7 +312,7 @@ export function getOutputDir( basePath, options.createSubFolder ? options.subFolderNameHelper - ? handlebarsParse(options, `{{ ${options.subFolderNameHelper} name }}`) + ? handlebarsParse(options, `{{ ${options.subFolderNameHelper} name }}`).toString() : options.name : undefined, ].filter(Boolean) as string[]) @@ -296,7 +340,7 @@ export function logInputFile( isDirOrGlob: boolean isGlob: boolean } -) { +): void { log( options, LogLevel.Debug, @@ -313,7 +357,7 @@ export function logInputFile( ) } -export function logInitStep(options: ScaffoldConfig) { +export function logInitStep(options: ScaffoldConfig): void { log(options, LogLevel.Debug, "Full config:", { name: options.name, templates: options.templates, diff --git a/tests/scaffold.test.ts b/tests/scaffold.test.ts index c68ffbd..e65622c 100644 --- a/tests/scaffold.test.ts +++ b/tests/scaffold.test.ts @@ -5,6 +5,7 @@ import { readdirSync, readFileSync } from "fs" import { Console } from "console" import { defaultHelpers } from "../src/utils" import { join } from "path" +import * as dateFns from "date-fns" const fileStructNormal = { input: { @@ -52,8 +53,16 @@ const fileStructHelpers = { }, output: {}, } -// let logsTemp: any = [] -// let logMock: any + +const fileStructDates = { + input: { + "now.txt": "Today is {{ now 'mmm' }}, time is {{ now 'HH:mm' }}", + "offset.txt": "Yesterday was {{ now 'mmm' -1 'days' }}, time is {{ now 'HH:mm' -1 'days' }}", + "custom.txt": "Custom date is {{ date customDate 'mmm' }}, time is {{ date customDate 'HH:mm' }}", + }, + output: {}, +} + function withMock(fileStruct: FileSystem.DirectoryItems, testFn: jest.EmptyFunction): jest.EmptyFunction { return () => { beforeEach(() => { @@ -85,7 +94,7 @@ describe("Scaffold", () => { verbose: 0, }) const data = readFileSync(join(process.cwd(), "output", "app_name.txt")) - expect(data.toString()).toBe("Hello, my app is app_name") + expect(data.toString()).toEqual("Hello, my app is app_name") }) test("should create with config", async () => { @@ -98,7 +107,7 @@ describe("Scaffold", () => { }) const data = readFileSync(join(process.cwd(), "output", "app_name", "app_name.txt")) - expect(data.toString()).toBe("Hello, my app is app_name") + expect(data.toString()).toEqual("Hello, my app is app_name") }) }) ) @@ -124,7 +133,7 @@ describe("Scaffold", () => { }) const data = readFileSync(join(process.cwd(), "output", "app_name.txt")) - expect(data.toString()).toBe("Hello, my value is 1") + expect(data.toString()).toEqual("Hello, my value is 1") }) test("should overwrite with config", async () => { @@ -146,7 +155,7 @@ describe("Scaffold", () => { }) const data = readFileSync(join(process.cwd(), "output", "app_name.txt")) - expect(data.toString()).toBe("Hello, my value is 2") + expect(data.toString()).toEqual("Hello, my value is 2") }) }) ) @@ -174,6 +183,43 @@ describe("Scaffold", () => { }) ).rejects.toThrow() + await expect( + Scaffold({ + name: "app_name", + output: "output", + templates: ["non-existing-input/non-existing-file.txt"], + data: { value: "1" }, + verbose: 0, + }) + ).rejects.toThrow() + + expect(() => readFileSync(join(process.cwd(), "output", "app_name.txt"))).toThrow() + }) + }) + ) + + describe( + "dry run", + withMock(fileStructNormal, () => { + let consoleMock1: jest.SpyInstance + beforeAll(() => { + consoleMock1 = jest.spyOn(console, "error").mockImplementation(() => void 0) + }) + + afterAll(() => { + consoleMock1.mockRestore() + }) + + test("should not write to disk", async () => { + Scaffold({ + name: "app_name", + output: "output", + templates: ["input"], + data: { value: "1" }, + verbose: 0, + dryRun: true, + }) + expect(() => readFileSync(join(process.cwd(), "output", "app_name.txt"))).toThrow() }) }) @@ -191,7 +237,7 @@ describe("Scaffold", () => { verbose: 0, }) const data = readFileSync(join(process.cwd(), "/custom-output/app_name/app_name.txt")) - expect(data.toString()).toBe("Hello, my app is app_name") + expect(data.toString()).toEqual("Hello, my app is app_name") }) }) ) @@ -226,56 +272,99 @@ describe("Scaffold", () => { ) describe( - "helpers", + "capitalization helpers", withMock(fileStructHelpers, () => { const _helpers: Record string> = { add1: (text) => text + " 1", } - describe("default helpers", () => { - test("should work", async () => { - await Scaffold({ - name: "app_name", - output: "output", - templates: ["input"], - verbose: 0, - helpers: _helpers, - }) - - const results = { - camelCase: "appName", - snakeCase: "app_name", - startCase: "App Name", - kebabCase: "app-name", - hyphenCase: "app-name", - pascalCase: "AppName", - lowerCase: "app_name", - upperCase: "APP_NAME", - } - for (const key in results) { - const file = readFileSync(join(process.cwd(), "output", "defaults", `${key}.txt`)) - expect(file.toString()).toEqual(results[key as keyof typeof results]) - } + test("should work", async () => { + await Scaffold({ + name: "app_name", + output: "output", + templates: ["input"], + verbose: 0, + helpers: _helpers, }) + + const results = { + camelCase: "appName", + snakeCase: "app_name", + startCase: "App Name", + kebabCase: "app-name", + hyphenCase: "app-name", + pascalCase: "AppName", + lowerCase: "app_name", + upperCase: "APP_NAME", + } + for (const key in results) { + const file = readFileSync(join(process.cwd(), "output", "defaults", `${key}.txt`)) + expect(file.toString()).toEqual(results[key as keyof typeof results]) + } }) - describe("custom helpers", () => { - test("should work", async () => { - await Scaffold({ - name: "app_name", - output: "output", - templates: ["input"], - verbose: 0, - helpers: _helpers, - }) + }) + ) + describe( + "date helpers", + withMock(fileStructDates, () => { + test("should work", async () => { + const now = new Date() + const yesterday = dateFns.add(new Date(), { days: -1 }) + const customDate = dateFns.formatISO(dateFns.add(new Date(), { days: -1 })) - const results = { - add1: "app_name 1", - } - for (const key in results) { - const file = readFileSync(join(process.cwd(), "output", "custom", `${key}.txt`)) - expect(file.toString()).toEqual(results[key as keyof typeof results]) - } + await Scaffold({ + name: "app_name", + output: "output", + templates: ["input"], + verbose: 0, + data: { customDate }, }) + + const nowFile = readFileSync(join(process.cwd(), "output", "now.txt")) + const offsetFile = readFileSync(join(process.cwd(), "output", "offset.txt")) + const customFile = readFileSync(join(process.cwd(), "output", "custom.txt")) + + // "now.txt": "Today is {{ now 'mmm' }}, time is {{ now 'HH:mm' }}", + // "offset.txt": "Yesterday was {{ now 'mmm' -1 'days' }}, time is {{ now 'HH:mm' -1 'days' }}", + // "custom.txt": "Custom date is {{ date customDate 'mmm' }}, time is {{ date customDate 'HH:mm' }}", + + expect(nowFile.toString()).toEqual( + `Today is ${dateFns.format(now, "mmm")}, time is ${dateFns.format(now, "HH:mm")}` + ) + expect(offsetFile.toString()).toEqual( + `Yesterday was ${dateFns.format(yesterday, "mmm")}, time is ${dateFns.format(yesterday, "HH:mm")}` + ) + expect(customFile.toString()).toEqual( + `Custom date is ${dateFns.format(dateFns.parseISO(customDate), "mmm")}, time is ${dateFns.format( + dateFns.parseISO(customDate), + "HH:mm" + )}` + ) + }) + }) + ) + describe( + "custom helpers", + withMock(fileStructHelpers, () => { + const _helpers: Record string> = { + add1: (text) => text + " 1", + } + test("should work", async () => { + await Scaffold({ + name: "app_name", + output: "output", + templates: ["input"], + verbose: 0, + helpers: _helpers, + }) + + const results = { + add1: "app_name 1", + } + for (const key in results) { + const file = readFileSync(join(process.cwd(), "output", "custom", `${key}.txt`)) + expect(file.toString()).toEqual(results[key as keyof typeof results]) + } }) }) ) @@ -291,8 +380,8 @@ describe("Scaffold", () => { verbose: 0, }) - const data = readFileSync(join(process.cwd(), "output", "app_name/app_name.txt")) - expect(data.toString()).toBe("Hello, my app is app_name") + const data = readFileSync(join(process.cwd(), "output", "app_name", "app_name.txt")) + expect(data.toString()).toEqual("Hello, my app is app_name") }) test("should work with default helper", async () => { @@ -305,8 +394,8 @@ describe("Scaffold", () => { subFolderNameHelper: "upperCase", }) - const data = readFileSync(join(process.cwd(), "output", "APP_NAME/app_name.txt")) - expect(data.toString()).toBe("Hello, my app is app_name") + const data = readFileSync(join(process.cwd(), "output", "APP_NAME", "app_name.txt")) + expect(data.toString()).toEqual("Hello, my app is app_name") }) test("should work with custom helper", async () => { @@ -322,8 +411,65 @@ describe("Scaffold", () => { }, }) - const data = readFileSync(join(process.cwd(), "output", "REPLACED/app_name.txt")) - expect(data.toString()).toBe("Hello, my app is app_name") + const data = readFileSync(join(process.cwd(), "output", "REPLACED", "app_name.txt")) + expect(data.toString()).toEqual("Hello, my app is app_name") + }) + }) + ) + describe( + "before write", + withMock(fileStructNormal, () => { + test("should work with no callback", async () => { + await Scaffold({ + name: "app_name", + output: "output", + templates: ["input"], + verbose: 0, + data: { + value: "value", + }, + }) + + const data = readFileSync(join(process.cwd(), "output", "app_name.txt")) + expect(data.toString()).toEqual("Hello, my app is app_name") + }) + + test("should work with custom callback", async () => { + await Scaffold({ + name: "app_name", + output: "output", + templates: ["input"], + verbose: 0, + data: { + value: "value", + }, + beforeWrite: (content, beforeContent, outputPath) => + [content.toString().toUpperCase(), beforeContent, outputPath].join(", "), + }) + + const data = readFileSync(join(process.cwd(), "output", "app_name.txt")) + expect(data.toString()).toEqual( + [ + "Hello, my app is app_name".toUpperCase(), + fileStructNormal.input["{{name}}.txt"], + join(process.cwd(), "output", "app_name.txt"), + ].join(", ") + ) + }) + test("should work with undefined response custom callback", async () => { + await Scaffold({ + name: "app_name", + output: "output", + templates: ["input"], + verbose: 0, + data: { + value: "value", + }, + beforeWrite: () => undefined, + }) + + const data = readFileSync(join(process.cwd(), "output", "app_name.txt")) + expect(data.toString()).toEqual("Hello, my app is app_name") }) }) ) diff --git a/tests/utils.test.ts b/tests/utils.test.ts index c9d071b..d3df48e 100644 --- a/tests/utils.test.ts +++ b/tests/utils.test.ts @@ -1,6 +1,7 @@ -import { handlebarsParse } from "../src/utils" +import { dateHelper, handlebarsParse, nowHelper } from "../src/utils" import { ScaffoldConfig } from "../src/types" import path from "path" +import * as dateFns from "date-fns" const blankConf: ScaffoldConfig = { verbose: 0, @@ -23,7 +24,7 @@ describe("Utils", () => { }) test("should work for windows paths", async () => { expect(handlebarsParse(blankConf, "C:\\exports\\{{name}}.txt", { isPath: true })).toEqual( - "C:\\exports\\test.txt" + Buffer.from("C:\\exports\\test.txt") ) }) }) @@ -36,7 +37,9 @@ describe("Utils", () => { Object.defineProperty(path, "sep", { value: origSep }) }) test("should work for non-windows paths", async () => { - expect(handlebarsParse(blankConf, "/home/test/{{name}}.txt", { isPath: true })).toEqual("/home/test/test.txt") + expect(handlebarsParse(blankConf, "/home/test/{{name}}.txt", { isPath: true })).toEqual( + Buffer.from("/home/test/test.txt") + ) }) }) test("should not do path escaping on non-path compiles", async () => { @@ -48,7 +51,41 @@ describe("Utils", () => { isPath: false, } ) - ).toEqual("/home/test/test {{escaped}}.txt") + ).toEqual(Buffer.from("/home/test/test {{escaped}}.txt")) + }) + }) + + describe("Helpers", () => { + describe("date helpers", () => { + describe("now", () => { + test("should work without extra params", () => { + const now = new Date() + const fmt = "yyyy-MM-dd HH:mm" + + expect(nowHelper(fmt)).toEqual(dateFns.format(now, fmt)) + }) + }) + + describe("date", () => { + test("should work with no offset params", () => { + const now = new Date() + const fmt = "yyyy-MM-dd HH:mm" + + expect(dateHelper(now.toISOString(), fmt)).toEqual(dateFns.format(now, fmt)) + }) + + test("should work with offset params", () => { + const now = new Date() + const fmt = "yyyy-MM-dd HH:mm" + + expect(dateHelper(now.toISOString(), fmt, -1, "days")).toEqual( + dateFns.format(dateFns.add(now, { days: -1 }), fmt) + ) + expect(dateHelper(now.toISOString(), fmt, 1, "months")).toEqual( + dateFns.format(dateFns.add(now, { months: 1 }), fmt) + ) + }) + }) }) }) }) diff --git a/yarn.lock b/yarn.lock index fcd60b6..d3ca4f2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -537,11 +537,6 @@ resolved "https://registry.yarnpkg.com/@tsconfig/node16/-/node16-1.0.1.tgz#a6ca6a9a0ff366af433f42f5f0e124794ff6b8f1" integrity sha512-FTgBI767POY/lKNDNbIzgAX6miIDBs6NTCbdlDb8TrWovHsSvaVIZDlTqym29C6UqhzwcJx4CYr+AlrMywA0cA== -"@types/args@^3.0.1": - version "3.0.1" - resolved "https://registry.yarnpkg.com/@types/args/-/args-3.0.1.tgz#59e8e787ed8a810408dd9a324157cc20b7001468" - integrity sha512-Mfre8T2comeJbw2D6W8mzQP+0Q8fpS7nkbHgatzU31tWsLs0Lkyc+ObdYfgV4SuMZn/n5MEWlxh2rc25125s0Q== - "@types/babel__core@^7.0.0", "@types/babel__core@^7.1.14": version "7.1.15" resolved "https://registry.yarnpkg.com/@types/babel__core/-/babel__core-7.1.15.tgz#2ccfb1ad55a02c83f8e0ad327cbc332f55eb1024" @@ -1055,6 +1050,11 @@ data-urls@^2.0.0: whatwg-mimetype "^2.3.0" whatwg-url "^8.0.0" +date-fns@^2.28.0: + version "2.28.0" + resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-2.28.0.tgz#9570d656f5fc13143e50c975a3b6bbeb46cd08b2" + integrity sha512-8d35hViGYx/QH0icHYCeLmsLmMUheMmTyV9Fcm6gvNwdw31yXXH+O85sOBJ+OLnLQMKZowvpKb6FgMIQjcpvQw== + debug@4, debug@^4.1.0, debug@^4.1.1: version "4.3.2" resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.2.tgz#f0a49c18ac8779e31d4a0c6029dfb76873c7428b"