diff --git a/docs/docs/usage/03-cli.md b/docs/docs/usage/03-cli.md index fd24b81..b46ea9f 100644 --- a/docs/docs/usage/03-cli.md +++ b/docs/docs/usage/03-cli.md @@ -11,24 +11,57 @@ Usage: simple-scaffold [options] To see this and more information anytime, add the `-h` or `--help` flag to your call, e.g. `npx simple-scaffold@latest -h`. -| Command \| alias | | -| ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `--name` \| `-n` | Name to be passed to the generated files. `{{name}}` and other data parameters inside contents and file names will be replaced accordingly. You may omit the `--name` or `-n` for this specific option. | -| `--config`\|`-c` | Filename or directory to load config from | -| `--git`\|`-g` | Git URL or GitHub path to load a template from. | -| `--key` \| `-k` | Key to load inside the config file. This overwrites the config key provided after the colon in `--config` (e.g. `--config scaffold.cmd.js:component`) | -| `--output` \| `-o` | Path to output to. If `--create-sub-folder` is enabled, the subfolder will be created inside this path. Default is current working directory. | -| `--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. | -| `--data` \| `-d` | Add custom data to the templates. By default, only your app name is included. | -| `--append-data` \| `-D` | Append additional custom data to the templates, which will overwrite `--data`, using an alternate syntax, which is easier to use with CLI: `-D key1=string -D key2:=raw` | -| `--create-sub-folder` \| `-s` | Create subfolder with the input name | -| `--sub-folder-name-helper` \| `-sh` | Default helper to apply to subfolder name when using `--create-sub-folder true`. | -| `--quiet` \| `-q` | Suppress output logs (Same as `--log-level none`) | -| `--log-level` \| `-l` | Determine amount of logs to display. The values are: `none \| debug \| info \| warn \| error`. The provided level will display messages of the same level or higher. | -| `--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. | -| `--help` \| `-h` | Show this help message | -| `--version` \| `-v` | Display version. | +| Command \| alias | | +| ----------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `--name` \| `-n` | Name to be passed to the generated files. `{{name}}` and other data parameters inside contents and file names will be replaced accordingly. You may omit the `--name` or `-n` for this specific option. | +| `--config`\|`-c` | Filename or directory to load config from | +| `--git`\|`-g` | Git URL or GitHub path to load a template from. | +| `--key` \| `-k` | Key to load inside the config file. This overwrites the config key provided after the colon in `--config` (e.g. `--config scaffold.cmd.js:component`) | +| `--output` \| `-o` | Path to output to. If `--create-sub-folder` is enabled, the subfolder will be created inside this path. Default is current working directory. | +| `--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. | +| `--data` \| `-d` | Add custom data to the templates. By default, only your app name is included. | +| `--append-data` \| `-D` | Append additional custom data to the templates, which will overwrite `--data`, using an alternate syntax, which is easier to use with CLI: `-D key1=string -D key2:=raw` | +| `--create-sub-folder` \| `-s` | Create subfolder with the input name | +| `--sub-folder-name-helper` \| `-sh` | Default helper to apply to subfolder name when using `--create-sub-folder true`. | +| `--quiet` \| `-q` | Suppress output logs (Same as `--log-level none`) | +| `--log-level` \| `-l` | Determine amount of logs to display. The values are: `none \| debug \| info \| warn \| error`. The provided level will display messages of the same level or higher. | +| `--before-write` \| `-B` | Run a script before writing the files. This can be a command or a path to a file. A temporary file path will be passed to the given command and the command should return a string for the final output. | +| `--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. | +| `--help` \| `-h` | Show this help message | +| `--version` \| `-v` | Display version. | + +### Before Write option + +This option allows you to preprocess a file before it is being written, such as running a formatter, +linter or other commands. + +To use this option, pass it the command you would like to run. The following tokens will be replaced +in your string: + +- `{{path}}` - the temporary file path for you to read from +- `{{rawpath}}` - a different file path containing the raw file contents **before** they were + handled by Handlebars.js. + +If none of these tokens are found, the regular (non-raw) path will be appended to the end of the +command. + +```shell +simple-scaffold -c . --before-write prettier +# command: prettier /tmp/somefile + +simple-scaffold -c . --before-write 'cat {{path}} | my-linter' +# command: cat /tmp/somefile | my-linter +``` + +The command should return the string to write to the file through standard output (stdout), and not +re-write the tmp file as it is not used for writing. Returning an empty string (after trimming) will +discard the result and write the original file contents. + +See +[beforeWrite](https://chenasraf.github.io/simple-scaffold/docs/api/interfaces/ScaffoldConfig#beforewrite) +Node.js API for more details. Instead of returning `undefined` to keep the default behavior, you can +output `''` for the same effect. ## Examples: diff --git a/docs/docs/usage/04-node.md b/docs/docs/usage/04-node.md index 58b9c8b..d21bab0 100644 --- a/docs/docs/usage/04-node.md +++ b/docs/docs/usage/04-node.md @@ -2,6 +2,8 @@ title: Node.js Usage --- +## Overview + You can build the scaffold yourself, if you want to create more complex arguments, scaffold groups, etc - simply pass a config object to the Scaffold function when you are ready to start. @@ -33,6 +35,19 @@ interface ScaffoldConfig { } ``` +### Before Write option + +This option allows you to preprocess a file before it is being written, such as running a formatter, +linter or other commands. + +To use this option, you can run any async/blocking command, and return a string as the final output +to be used as the file contents. + +Returning `undefined` will keep the file contents as-is, after normal Handlebars.js procesing by +Simple Scaffold. + +## Example + This is an example of loading a complete scaffold via Node.js: ```typescript @@ -50,6 +65,8 @@ const config = { helpers: { twice: (text) => [text, text].join(" ") }, + // return a string to replace the final file contents after pre-processing, or `undefined` + // to keep it as-is beforeWrite: (content, rawContent, outputPath) => content.toString().toUpperCase() } diff --git a/examples/test-input/Component/{{pascalCase name}}.tsx b/examples/test-input/Component/{{pascalCase name}}.tsx index 61a5b7c..56d05e6 100644 --- a/examples/test-input/Component/{{pascalCase name}}.tsx +++ b/examples/test-input/Component/{{pascalCase name}}.tsx @@ -1,7 +1,7 @@ import * as React from "react" -import * as css from "./{{pascalCae name}}.css" +import * as css from "./{{pascalCase name}}.css" -class {{pascalCae name}} extends React.Component { +class {{pascalCase name}} extends React.Component { private {{ property }} constructor(props: any) { @@ -10,8 +10,8 @@ class {{pascalCae name}} extends React.Component { } public render() { - return
+ return
} } -export default {pascalCae nName}} +export default {{pascalCase name}} diff --git a/src/cmd.ts b/src/cmd.ts index 8e20610..1823396 100644 --- a/src/cmd.ts +++ b/src/cmd.ts @@ -1,15 +1,15 @@ #!/usr/bin/env node -import os from "node:os" +import path from "node:path" +import fs from "node:fs/promises" import { massarg } from "massarg" import chalk from "chalk" import { ListCommandCliOptions, LogLevel, ScaffoldCmdConfig } from "./types" import { Scaffold } from "./scaffold" -import path from "node:path" -import fs from "node:fs/promises" import { getConfigFile, parseAppendData, parseConfigFile } from "./config" import { log } from "./logger" import { MassargCommand } from "massarg/command" +import { getUniqueTmpPath as generateUniqueTmpPath } from "./file" export async function parseCliArgs(args = process.argv.slice(2)) { const isProjectRoot = Boolean(await fs.stat(path.join(__dirname, "package.json")).catch(() => false)) @@ -30,7 +30,7 @@ export async function parseCliArgs(args = process.argv.slice(2)) { return } log(config, LogLevel.info, `Simple Scaffold v${pkg.version}`) - const tmpPath = path.resolve(os.tmpdir(), `scaffold-config-${Date.now()}`) + const tmpPath = generateUniqueTmpPath() try { log(config, LogLevel.debug, "Parsing config file...", config) const parsed = await parseConfigFile(config, tmpPath) @@ -144,6 +144,14 @@ export async function parseCliArgs(args = process.argv.slice(2)) { return val }, }) + .option({ + name: "before-write", + aliases: ["B"], + description: + "Run a script before writing the files. This can be a command or a path to a" + + " file. A temporary file path will be passed to the given command and the command should " + + "return a string for the final output.", + }) .flag({ name: "dry-run", aliases: ["dr"], @@ -163,7 +171,7 @@ export async function parseCliArgs(args = process.argv.slice(2)) { aliases: ["ls"], description: "List all available templates for a given config. See `list -h` for more information.", run: async (_config) => { - const tmpPath = path.resolve(os.tmpdir(), `scaffold-config-${Date.now()}`) + const tmpPath = generateUniqueTmpPath() const config = { templates: [], name: "", diff --git a/src/config.ts b/src/config.ts index 6e86158..b88c457 100644 --- a/src/config.ts +++ b/src/config.ts @@ -16,7 +16,8 @@ import { handlebarsParse } from "./parser" import { log } from "./logger" import { resolve, wrapNoopResolver } from "./utils" import { getGitConfig } from "./git" -import { isDir, pathExists } from "./file" +import { createDirIfNotExists, getUniqueTmpPath, isDir, pathExists } from "./file" +import { exec, spawn } from "node:child_process" /** @internal */ export function getOptionValueForFile( @@ -80,7 +81,7 @@ export async function getConfigFile(config: ScaffoldCmdConfig, tmpPath: string): /** @internal */ export async function parseConfigFile(config: ScaffoldCmdConfig, tmpPath: string): Promise { - let output: ScaffoldConfig = config + let output: ScaffoldConfig = { ...config, beforeWrite: undefined } if (config.quiet) { config.logLevel = LogLevel.none @@ -101,6 +102,7 @@ export async function parseConfigFile(config: ScaffoldCmdConfig, tmpPath: string output = { ...config, ...imported, + beforeWrite: undefined, data: { ...(imported as any).data, ...config.data, @@ -109,9 +111,12 @@ export async function parseConfigFile(config: ScaffoldCmdConfig, tmpPath: string } output.data = { ...output.data, ...config.appendData } + output.beforeWrite = config.beforeWrite ? wrapBeforeWrite(config, config.beforeWrite) : undefined + if (!output.name) { throw new Error("simple-scaffold: Missing required option: name") } + log(output, LogLevel.debug, "Parsed config", output) return output } @@ -182,3 +187,72 @@ export async function findConfigFile(root: string): Promise { } throw new Error(`Could not find config file in git repo`) } + +function wrapBeforeWrite( + config: LogConfig & Pick, + beforeWrite: string, +): ScaffoldConfig["beforeWrite"] { + return async (content, rawContent, outputFile) => { + const tmpPath = path.join(getUniqueTmpPath(), path.basename(outputFile)) + await createDirIfNotExists(path.dirname(tmpPath), config) + const ext = path.extname(outputFile) + const rawTmpPath = tmpPath.replace(ext, ".raw" + ext) + try { + log(config, LogLevel.debug, "Parsing beforeWrite command", beforeWrite) + let cmd = await prepareBeforeWriteCmd({ beforeWrite, tmpPath, content, rawTmpPath, rawContent }) + const result = await new Promise((resolve, reject) => { + log(config, LogLevel.debug, "Running parsed beforeWrite command:", cmd) + const proc = exec(cmd) + proc.stdout!.on("data", (data) => { + if (data.trim()) { + resolve(data.toString()) + } else { + resolve(undefined) + } + }) + proc.stderr!.on("data", (data) => { + reject(data.toString()) + }) + }) + return result + } catch (e) { + log(config, LogLevel.debug, e) + log(config, LogLevel.warning, "Error running beforeWrite command, returning original content") + return undefined + } finally { + await fs.rm(tmpPath, { force: true }) + await fs.rm(rawTmpPath, { force: true }) + } + } +} + +async function prepareBeforeWriteCmd({ + beforeWrite, + tmpPath, + content, + rawTmpPath, + rawContent, +}: { + beforeWrite: string + tmpPath: string + content: Buffer + rawTmpPath: string + rawContent: Buffer +}): Promise { + let cmd: string = "" + const pathReg = /\{\{\s*path\s*\}\}/gi + const rawPathReg = /\{\{\s*rawpath\s*\}\}/gi + if (pathReg.test(beforeWrite)) { + await fs.writeFile(tmpPath, content) + cmd = beforeWrite.replaceAll(pathReg, tmpPath) + } + if (rawPathReg.test(beforeWrite)) { + await fs.writeFile(rawTmpPath, rawContent) + cmd = beforeWrite.replaceAll(rawPathReg, rawTmpPath) + } + if (!cmd) { + await fs.writeFile(tmpPath, content) + cmd = [beforeWrite, tmpPath].join(" ") + } + return cmd +} diff --git a/src/file.ts b/src/file.ts index 0048139..3d0bdf6 100644 --- a/src/file.ts +++ b/src/file.ts @@ -1,7 +1,8 @@ +import os from "node:os" import path from "node:path" -import { F_OK } from "node:constants" -import { LogLevel, ScaffoldConfig } from "./types" import fs from "node:fs/promises" +import { F_OK } from "node:constants" +import { LogConfig, LogLevel, ScaffoldConfig } from "./types" import { glob, hasMagic } from "glob" import { log } from "./logger" import { getOptionValueForFile } from "./config" @@ -10,7 +11,10 @@ import { handleErr } from "./utils" const { stat, access, mkdir, readFile, writeFile } = fs -export async function createDirIfNotExists(dir: string, config: ScaffoldConfig): Promise { +export async function createDirIfNotExists( + dir: string, + config: LogConfig & Pick, +): Promise { if (config.dryRun) { log(config, LogLevel.info, `Dry Run. Not creating dir ${dir}`) return @@ -142,6 +146,7 @@ export async function copyFileTransformed( if (exists && overwrite) { log(config, LogLevel.info, `File ${outputPath} exists, overwriting`) } + log(config, LogLevel.debug, `Processing file ${inputPath}`) const templateBuffer = await readFile(inputPath) const unprocessedOutputContents = handlebarsParse(config, templateBuffer) const finalOutputContents = @@ -149,7 +154,6 @@ export async function copyFileTransformed( if (!config.dryRun) { await writeFile(outputPath, finalOutputContents) - log(config, LogLevel.info, "Done.") } else { log(config, LogLevel.info, "Dry Run. Output should be:") log(config, LogLevel.info, finalOutputContents.toString()) @@ -157,6 +161,7 @@ export async function copyFileTransformed( } else if (exists) { log(config, LogLevel.info, `File ${outputPath} already exists, skipping`) } + log(config, LogLevel.info, "Done.") } export function getOutputDir(config: ScaffoldConfig, outputPathOpt: string, basePath: string): string { @@ -209,3 +214,8 @@ export async function handleTemplateFile( } }) } + +/** @internal */ +export function getUniqueTmpPath(): string { + return path.resolve(os.tmpdir(), `scaffold-config-${Date.now()}-${Math.random().toString(36).slice(2)}`) +} diff --git a/src/logger.ts b/src/logger.ts index 7ebd8df..ee87af8 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -1,3 +1,4 @@ +import util from "util" import { LogConfig, LogLevel, ScaffoldConfig } from "./types" import chalk from "chalk" @@ -30,7 +31,7 @@ export function log(config: LogConfig, level: LogLevel, ...obj: any[]): void { i instanceof Error ? chalkFn(i, JSON.stringify(i, undefined, 1), i.stack) : typeof i === "object" - ? chalkFn(JSON.stringify(i, undefined, 1)) + ? util.inspect(i, { depth: null, colors: true }) : chalkFn(i), ), ) diff --git a/src/parser.ts b/src/parser.ts index 1bb911e..e0783a5 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -121,7 +121,7 @@ export function handlebarsParse( return Buffer.from(outputContents) } catch (e) { log(config, LogLevel.debug, e) - log(config, LogLevel.info, "Couldn't parse file with handlebars, returning original content") + log(config, LogLevel.warning, "Couldn't parse file with handlebars, returning original content") return Buffer.from(templateBuffer) } } diff --git a/src/types.ts b/src/types.ts index 1692e14..f73e9f8 100644 --- a/src/types.ts +++ b/src/types.ts @@ -368,6 +368,8 @@ export type ScaffoldCmdConfig = { git?: string /** Display version */ version: boolean + /** Run a script before writing the files. This can be a command or a path to a file. The file contents will be passed to the given command. */ + beforeWrite?: string } /**