Skip to content

Commit f577d85

Browse files
committed
Merge branch 'feat/ios-header' of https://github.com/IDEMSInternational/parenting-app-ui into feat/ios-header
2 parents 6a12a65 + e32f472 commit f577d85

File tree

15 files changed

+540
-49
lines changed

15 files changed

+540
-49
lines changed

package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@
8888
"intro.js": "^3.4.0",
8989
"jquery": "^3.7.1",
9090
"jquery-touchswipe": "^1.6.19",
91-
"katex": "^0.16.9",
91+
"katex": "^0.16.10",
9292
"lottie-web": "^5.12.2",
9393
"marked": "^2.1.3",
9494
"mergexml": "^1.2.3",
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
node_modules
2+
test/**/*.*
3+
!test/**/*.template.*
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
# Env Replace
2+
3+
Populate variables hardcoded into files from environment
4+
5+
## Setup Template Files
6+
All file formats are supported, however files containing variables should be named with `.template.` in the filename, e.g. `config.template.ts`.
7+
8+
Within a file variables for replacement should be wrapped with curly braces, e.g.
9+
10+
_config.template.ts_
11+
```ts
12+
const config = {
13+
appId: ${APP_ID}
14+
}
15+
```
16+
17+
## Replace Templated Files
18+
```ts
19+
import {replaceFiles} from '@idemsInternational/env-replace'
20+
21+
await replaceFiles()
22+
```
23+
24+
## Configuration
25+
The replace method can be customised with various parameters
26+
27+
28+
## Future TODOs
29+
30+
**Variable Parsing**
31+
Future syntax could include utility helpers, e.g.
32+
```ts
33+
const config = {
34+
nested_config: JSON(${STRING_JSON})
35+
}
36+
```
37+
38+
**Variable Fallback**
39+
Adding support for default/fallback values like some shell interpolations
40+
```ts
41+
const config = {
42+
appId: ${APP_ID:-debug_app}
43+
}
44+
```
45+
46+
**Env file**
47+
Pass `envFile` path to load variables from
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
{
2+
"name": "@idemsInternational/env-replace",
3+
"version": "1.0.0",
4+
"description": "",
5+
"main": "src/index.ts",
6+
"types": "src/index.ts",
7+
"scripts": {
8+
"test": "ts-mocha -p tsconfig.spec.json src/**/*.spec.ts"
9+
},
10+
"dependencies": {
11+
"glob": "^10.3.10"
12+
},
13+
"devDependencies": {
14+
"@types/chai": "^4.2.22",
15+
"@types/expect": "^24.3.0",
16+
"@types/mocha": "^10.0.6",
17+
"@types/node": "^16.18.9",
18+
"chai": "^4.3.4",
19+
"mocha": "^10.3.0",
20+
"ts-mocha": "^10.0.0",
21+
"typescript": "~4.2.4"
22+
}
23+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { resolve } from "path";
2+
import { expect } from "expect";
3+
import { envReplace, IEnvReplaceConfig } from "./index";
4+
import { readFileSync, readdirSync, rmSync } from "fs";
5+
6+
const testDir = resolve(__dirname, "../test");
7+
8+
const TEST_ENV = {
9+
STRING_VAR: "example string",
10+
BOOL_VAR: true,
11+
INT_VAR: 8,
12+
CHILD_VAR: "example child",
13+
};
14+
15+
// Clean any generated output files
16+
function cleanTestDir(dir: string) {
17+
const testFiles = readdirSync(dir, { withFileTypes: true });
18+
for (const testFile of testFiles) {
19+
if (testFile.isDirectory()) {
20+
cleanTestDir(resolve(dir, testFile.name));
21+
}
22+
if (testFile.isFile() && !testFile.name.includes("template.")) {
23+
const fullPath = resolve(dir, testFile.name);
24+
rmSync(fullPath);
25+
}
26+
}
27+
}
28+
29+
describe("Env Replace", () => {
30+
beforeEach(() => {
31+
cleanTestDir(testDir);
32+
});
33+
34+
it("Replaces file env", async () => {
35+
// populate global process env to use alongside hardcoded env
36+
process.env.GLOBAL_VAR = "example global";
37+
const res = await envReplace.replaceFiles({
38+
cwd: testDir,
39+
envAdditional: TEST_ENV,
40+
excludeVariables: ["EXCLUDED_VAR"],
41+
});
42+
// list of replaced values
43+
expect(res).toEqual({
44+
"test_basic.json": {
45+
STRING_VAR: "example string",
46+
GLOBAL_VAR: "example global",
47+
BOOL_VAR: true,
48+
INT_VAR: 8,
49+
},
50+
"child/.env": { STRING_VAR: "example string" },
51+
});
52+
// raw file output (including non-replaced)
53+
const outputFile = readFileSync(resolve(testDir, "test_basic.json"), { encoding: "utf-8" });
54+
expect(JSON.parse(outputFile)).toEqual({
55+
test_string: "example string",
56+
test_global: "example global",
57+
test_excluded: "${EXCLUDED_VAR}",
58+
test_non_var: "example non var",
59+
test_boolean: true,
60+
test_int: 8,
61+
});
62+
});
63+
64+
it("Supports file matching glob override", async () => {
65+
const res = await envReplace.replaceFiles({
66+
rawGlobInput: "child/*.*",
67+
envAdditional: TEST_ENV,
68+
cwd: testDir,
69+
});
70+
expect(res).toEqual({ "child/.env": { STRING_VAR: "example string" } });
71+
});
72+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
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 };
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
test_string="${STRING_VAR}"
2+
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"test_string": "${STRING_VAR}",
3+
"test_global": "${GLOBAL_VAR}",
4+
"test_excluded": "${EXCLUDED_VAR}",
5+
"test_non_var":"example non var",
6+
"test_boolean": ${BOOL_VAR},
7+
"test_int": ${INT_VAR}
8+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
{
2+
"compilerOptions": {
3+
"target": "ESNext",
4+
"moduleResolution": "node",
5+
"lib": ["ESNext"],
6+
"module": "commonjs",
7+
"esModuleInterop": true,
8+
"resolveJsonModule": true,
9+
"outDir": "build",
10+
"types": ["node"],
11+
"typeRoots": ["./node_modules/@types", "./types/**"]
12+
},
13+
"include": ["src/**/*.ts"],
14+
"exclude": ["src/**/*.spec.ts"]
15+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"extends": "./tsconfig.json",
3+
"compilerOptions": {
4+
"outDir": "./out-tsc/spec",
5+
"types": ["mocha", "chai", "node"]
6+
},
7+
"files": [],
8+
"include": ["src/**/*.spec.ts", "src/**/*.d.ts"]
9+
}

0 commit comments

Comments
 (0)