|
| 1 | +import { readFileSync, writeFileSync } from "fs"; |
| 2 | +import { GlobOptionsWithFileTypesUnset, glob } from "glob"; |
| 3 | +import { resolve } from "path"; |
| 4 | + |
| 5 | +export interface IEnvReplaceConfig { |
| 6 | + cwd: string; |
| 7 | + |
| 8 | + /** List of input folder glob patterns to include, default ['**'] */ |
| 9 | + includeFolders: string[]; |
| 10 | + |
| 11 | + /** Filename pattern to find */ |
| 12 | + fileNameFind: string; |
| 13 | + |
| 14 | + /** String to replace filename pattern match with to output to different file, default '' */ |
| 15 | + fileNameReplace: string; |
| 16 | + |
| 17 | + /** Additional variables to populate to env. Will be merged with global env */ |
| 18 | + envAdditional: Record<string, any>; |
| 19 | + |
| 20 | + /** List of folder glob patterns to exclude, */ |
| 21 | + excludeFolders: string[]; |
| 22 | + |
| 23 | + /** Specify list of variable names to exclude, default includes all */ |
| 24 | + excludeVariables: string[]; |
| 25 | + |
| 26 | + /** Specify list of variable names to include, default includes all */ |
| 27 | + includeVariables: string[]; |
| 28 | + |
| 29 | + /** Override generated globs from input folder and filename patters */ |
| 30 | + rawGlobInput: string; |
| 31 | + |
| 32 | + /** Additional options to provide directly to input glob match */ |
| 33 | + rawGlobOptions: GlobOptionsWithFileTypesUnset; |
| 34 | + |
| 35 | + /** Throw an error if environment variable is missing. Default true */ |
| 36 | + throwOnMissing: boolean; |
| 37 | +} |
| 38 | + |
| 39 | +const DEFAULT_CONFIG: IEnvReplaceConfig = { |
| 40 | + cwd: process.cwd(), |
| 41 | + envAdditional: {}, |
| 42 | + excludeFolders: ["node_modules/**"], |
| 43 | + excludeVariables: [], |
| 44 | + fileNameFind: ".template.", |
| 45 | + fileNameReplace: ".", |
| 46 | + includeFolders: ["**"], |
| 47 | + includeVariables: [], |
| 48 | + rawGlobInput: "", |
| 49 | + rawGlobOptions: {}, |
| 50 | + throwOnMissing: true, |
| 51 | +}; |
| 52 | + |
| 53 | +/** |
| 54 | + * Regex pattern used to identify templated variable names. |
| 55 | + * Supports minimal alphanumeric and underscore characters. |
| 56 | + * Uses capture group to detect variable name within ${VAR_NAME} syntax |
| 57 | + */ |
| 58 | +const VARIABLE_NAME_REGEX = /\${([a-z0-9_]*)}/gi; |
| 59 | + |
| 60 | +/** |
| 61 | + * Utility class to handle replacing placeholder templates with environment variables |
| 62 | + * Inspired by https://github.com/danday74/envsub |
| 63 | + * |
| 64 | + * @example |
| 65 | + * ```ts |
| 66 | + * import { envReplace, IEnvReplaceConfig } from "@idemsInternational/env-replace"; |
| 67 | + * |
| 68 | + * // default config processes `.template.` files using process env |
| 69 | + * const config: IEnvReplaceConfig = {} |
| 70 | + * envReplace.replaceFiles(config) |
| 71 | + * ``` |
| 72 | + * @see IEnvReplaceConfig for full configuration options |
| 73 | + * |
| 74 | + */ |
| 75 | +class EnvReplaceClass { |
| 76 | + private config: IEnvReplaceConfig; |
| 77 | + private globalEnv: Record<string, any>; |
| 78 | + private summary: { [inputName: string]: { [variableName: string]: any } }; |
| 79 | + |
| 80 | + /** |
| 81 | + * Replace templated variables in files, as matched via config |
| 82 | + * @returns list of variables replaced, with full output written to file |
| 83 | + */ |
| 84 | + public async replaceFiles(config: Partial<IEnvReplaceConfig>) { |
| 85 | + this.config = { ...DEFAULT_CONFIG, ...config }; |
| 86 | + this.globalEnv = { ...process.env, ...this.config.envAdditional }; |
| 87 | + this.summary = {}; |
| 88 | + |
| 89 | + const { excludeFolders, cwd, rawGlobOptions, fileNameFind, fileNameReplace } = this.config; |
| 90 | + |
| 91 | + const inputGlob = this.generateInputGlob(); |
| 92 | + const inputNames = await glob(inputGlob, { |
| 93 | + ignore: excludeFolders, |
| 94 | + cwd, |
| 95 | + dot: true, |
| 96 | + posix: true, |
| 97 | + ...rawGlobOptions, |
| 98 | + }); |
| 99 | + |
| 100 | + for (const inputName of inputNames) { |
| 101 | + const filepath = resolve(cwd, inputName); |
| 102 | + const sourceContent = readFileSync(filepath, { encoding: "utf-8" }); |
| 103 | + |
| 104 | + // determine variables to replace and values to replace with |
| 105 | + const variablesToReplace = this.generateReplacementList(sourceContent); |
| 106 | + const replacementEnv = this.generateReplacementEnv(variablesToReplace); |
| 107 | + |
| 108 | + // handle replacement |
| 109 | + const outputName = inputName.replace(fileNameFind, fileNameReplace); |
| 110 | + this.summary[outputName] = {}; |
| 111 | + |
| 112 | + const replaceContent = this.generateReplaceContent(outputName, sourceContent, replacementEnv); |
| 113 | + const outputPath = resolve(cwd, outputName); |
| 114 | + writeFileSync(outputPath, replaceContent); |
| 115 | + } |
| 116 | + return this.summary; |
| 117 | + } |
| 118 | + |
| 119 | + private generateReplaceContent( |
| 120 | + outputName: string, |
| 121 | + sourceContent: string, |
| 122 | + replacementEnv: Record<string, any> |
| 123 | + ) { |
| 124 | + let replaced = new String(sourceContent).toString(); |
| 125 | + for (const [variableName, replaceValue] of Object.entries(replacementEnv)) { |
| 126 | + this.summary[outputName][variableName] = replaceValue; |
| 127 | + replaced = replaced.replaceAll("${" + variableName + "}", replaceValue); |
| 128 | + } |
| 129 | + return replaced; |
| 130 | + } |
| 131 | + |
| 132 | + private generateReplacementEnv(variableNames: string[]) { |
| 133 | + const { throwOnMissing } = this.config; |
| 134 | + const replacementEnv: Record<string, any> = {}; |
| 135 | + for (const variableName of variableNames) { |
| 136 | + let replaceValue = this.globalEnv[variableName]; |
| 137 | + replacementEnv[variableName] = replaceValue; |
| 138 | + if (replaceValue === undefined) { |
| 139 | + const msg = `No value for environment variable \${${variableName}}`; |
| 140 | + if (throwOnMissing) throw new Error(msg); |
| 141 | + else console.warn(msg); |
| 142 | + } |
| 143 | + } |
| 144 | + return replacementEnv; |
| 145 | + } |
| 146 | + |
| 147 | + /** |
| 148 | + * Extract list of all variables within file contents matching variable regex, |
| 149 | + * convert to unique list and filter depending on include/exclude config |
| 150 | + */ |
| 151 | + private generateReplacementList(contents: string) { |
| 152 | + // generate list of all required matches in advance to ensure |
| 153 | + // env variables exist as required |
| 154 | + const matches = Array.from(contents.matchAll(VARIABLE_NAME_REGEX)); |
| 155 | + // use list of unique variable names for replacement |
| 156 | + const uniqueVariables = [...new Set(matches.map((m) => m[1]))]; |
| 157 | + const { includeVariables, excludeVariables } = this.config; |
| 158 | + // filter replacement list if named list of variables to include/exclude set |
| 159 | + if (includeVariables.length > 0) { |
| 160 | + return uniqueVariables.filter((name) => includeVariables.includes(name)); |
| 161 | + } |
| 162 | + if (excludeVariables.length > 0) { |
| 163 | + return uniqueVariables.filter((name) => !excludeVariables.includes(name)); |
| 164 | + } |
| 165 | + return uniqueVariables; |
| 166 | + } |
| 167 | + |
| 168 | + /** Generate a glob matching pattern for all included paths with filename pattern match suffix */ |
| 169 | + private generateInputGlob() { |
| 170 | + const { includeFolders, fileNameFind, rawGlobInput } = this.config; |
| 171 | + // use raw glob override if provided |
| 172 | + if (rawGlobInput) return rawGlobInput; |
| 173 | + // create match patterns for all included folders and file name patters |
| 174 | + const globs = []; |
| 175 | + for (const folder of includeFolders) { |
| 176 | + globs.push(`${folder}/*${fileNameFind}*`); |
| 177 | + } |
| 178 | + return globs.join("|"); |
| 179 | + } |
| 180 | +} |
| 181 | +const envReplace = new EnvReplaceClass(); |
| 182 | +export { envReplace }; |
0 commit comments