@nci-gis/js-tmpl 0.0.1-beta.1 → 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,13 +1,15 @@
1
1
  # js-tmpl
2
2
 
3
- > A lightweight, deterministic file templating engine built on Handlebars
3
+ > A lightweight, deterministic file templating engine built on Handlebars.
4
+ >
5
+ > An explicit file templating engine for developers who care about **control, predictability, and composability**.
4
6
 
5
7
  [![npm version](https://img.shields.io/npm/v/@nci-gis/js-tmpl.svg)](https://www.npmjs.com/package/@nci-gis/js-tmpl)
6
8
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
7
9
 
8
10
  ## What is js-tmpl?
9
11
 
10
- js-tmpl is a **pure transformation layer** that generates files and directory structures from templates with predictable, explicit behavior.
12
+ js-tmpl is a **pure transformation layer** that turns **templates + data → files**, nothing more, nothing less.
11
13
 
12
14
  It's designed for:
13
15
 
@@ -18,17 +20,33 @@ It's designed for:
18
20
 
19
21
  **Not a framework. Not a workflow tool. Just a focused rendering engine.**
20
22
 
23
+ ## Is js-tmpl for you?
24
+
25
+ js-tmpl is a good fit if you:
26
+
27
+ - embed templating inside other tools or pipelines
28
+ - want **the same input to always produce the same output**
29
+ - prefer explicit configuration over conventions
30
+ - need programmatic control, not just a CLI
31
+
32
+ It may **not** be a good fit if you want:
33
+
34
+ - opinionated project generators
35
+ - convention-based magic
36
+ - interactive scaffolding workflows
37
+
21
38
  ## Why js-tmpl?
22
39
 
23
- Most template tools are either too simple (basic string replacement) or too complex (opinionated frameworks). js-tmpl fills the gap:
40
+ Most templating tools fail in one of two ways:
41
+
42
+ - they are too simple to scale beyond string replacement
43
+ - or too opinionated to embed safely in larger systems
24
44
 
25
- - ✅ **Engine-First**: Programmatic API, CLI is secondary
26
- - ✅ **Deterministic**: Same input → same output, always
27
- - ✅ **Explicit**: No magic defaults or hidden conventions
28
- - ✅ **Composable**: Small, focused layers
29
- - ✅ **Embeddable**: Designed to integrate into larger tools
45
+ js-tmpl sits intentionally in between.
30
46
 
31
- See [docs/00-Motivation.md](docs/00-Motivation.md) for the full story.
47
+ See [Motivation](docs/Motivation.md) - The full story.
48
+
49
+ See [Design Principles](docs/PRINCIPLES.md) - Core philosophy guiding all decisions.
32
50
 
33
51
  ## Features
34
52
 
@@ -40,6 +58,52 @@ See [docs/00-Motivation.md](docs/00-Motivation.md) for the full story.
40
58
  - 🔒 **No Global State** - Isolated render passes, no pollution
41
59
  - 📝 **YAML/JSON Support** - Load values from either format
42
60
 
61
+ ## Fixed Rules for Minimal Auto-Discovery
62
+
63
+ js-tmpl follows the principle **"Explicit Over Implicit"** - most configuration must be provided explicitly. However, for developer convenience, exactly **ONE** type of auto-discovery is allowed:
64
+
65
+ ### Project Configuration File (Optional)
66
+
67
+ js-tmpl will search for a project config file in **exactly these locations**, in this order, relative to the current working directory:
68
+
69
+ 1. `js-tmpl.config.yaml` (highest priority)
70
+ 2. `js-tmpl.config.yml`
71
+ 3. `js-tmpl.config.json`
72
+ 4. `config/js-tmpl.yaml`
73
+ 5. `config/js-tmpl.json` (lowest priority)
74
+
75
+ **First match wins.** If no config file is found, internal defaults are used.
76
+
77
+ ### What is NOT Auto-Discovered
78
+
79
+ Everything else must be **explicitly specified**:
80
+
81
+ - ✅ **Values file** - Required via `--values` flag or `valuesFile` config
82
+ - ✅ **Template directory** - Must be in config or defaults to `templates/`
83
+ - ✅ **Output directory** - Must be in config or defaults to `dist/`
84
+ - ✅ **Partials directory** - Must be in config; not loaded if omitted
85
+
86
+ ### Override Auto-Discovery
87
+
88
+ You can bypass auto-discovery entirely:
89
+
90
+ ```bash
91
+ # Explicit config file (no auto-discovery)
92
+ js-tmpl render --values data.yaml --config-file /path/to/custom-config.yaml
93
+
94
+ # No config file (use defaults only)
95
+ js-tmpl render --values data.yaml --template-dir ./templates --out ./dist
96
+ ```
97
+
98
+ ### Why These Rules?
99
+
100
+ 1. **Predictable** - Fixed search order, no magic
101
+ 2. **Minimal** - Only config file location is auto-discovered
102
+ 3. **Overridable** - Always use `--config-file` for explicit control
103
+ 4. **Documented** - You're reading the complete list right now
104
+
105
+ **These are the ONLY auto-discovery rules. Nothing else is implicit.**
106
+
43
107
  ## Installation
44
108
 
45
109
  ```bash
@@ -75,14 +139,9 @@ templates/
75
139
  **Template content** (`templates/${project.name}/config.json.hbs`):
76
140
 
77
141
  ```handlebars
78
- {
79
- "name": "{{project.name}}",
80
- "version": "{{project.version}}",
81
- "server": {
82
- "port": {{config.port}},
83
- "host": "{{config.host}}"
84
- }
85
- }
142
+ { "name": "{{project.name}}", "version": "{{project.version}}", "server": {
143
+ "port":
144
+ {{config.port}}, "host": "{{config.host}}" } }
86
145
  ```
87
146
 
88
147
  ### 3. Render templates
@@ -101,7 +160,7 @@ import { resolveConfig, renderDirectory } from '@nci-gis/js-tmpl';
101
160
  const config = resolveConfig({
102
161
  valuesFile: './values.yaml',
103
162
  templateDir: './templates',
104
- outDir: './dist'
163
+ outDir: './dist',
105
164
  });
106
165
 
107
166
  await renderDirectory(config);
@@ -128,14 +187,11 @@ CLI arguments
128
187
 
129
188
  ### View Model
130
189
 
131
- Templates receive a view object:
190
+ Templates receive a view object containing your values data plus an `env` object with allowlisted environment variables. The `env` key is reserved — if your values file contains a top-level `env` key, a warning is logged and it is overwritten.
132
191
 
133
- ```javascript
134
- {
135
- ...valuesFromFile, // Your YAML/JSON data
136
- env: process.env // Environment variables
137
- }
138
- ```
192
+ Use `envKeys` and `envPrefix` in your config file or via CLI (`--env-keys`, `--env-prefix`) to control which environment variables are exposed. Without either, `env` is an empty object `{}`.
193
+
194
+ See [docs/API.md](docs/API.md#view-object) for full details, examples, and recommended conventions.
139
195
 
140
196
  Access in templates:
141
197
 
@@ -158,21 +214,40 @@ templates/
158
214
 
159
215
  ### Partial System
160
216
 
161
- **Root partials** (`_name.hbs`):
217
+ Each render pass uses an isolated Handlebars instance. Directory structure maps to partial names:
162
218
 
163
219
  ```text
164
220
  templates.partials/
165
- └── _header.hbs → {{> header}}
221
+ ├── header.hbs → {{> header}}
222
+ ├── components/
223
+ │ ├── button.hbs → {{> components.button}}
224
+ │ └── forms/
225
+ │ └── login.hbs → {{> components.forms.login}}
166
226
  ```
167
227
 
168
- **Namespaced partials** (`@group/name.hbs`):
228
+ **`@` directories** flatten their contents (filename only, no namespace):
169
229
 
170
230
  ```text
171
- templates.partials/
172
- └── @common/
173
- └── metadata.hbs → {{> common.metadata}}
231
+ ├── @helpers/
232
+ │ └── date.hbs → {{> date}}
174
233
  ```
175
234
 
235
+ Duplicate partial names throw an error. Names must be alphanumeric + underscore only. See [API docs](docs/API.md#partial-system) for details.
236
+
237
+ ## Mental Model
238
+
239
+ > ⚠️ Design note
240
+ > js-tmpl prefers failing loudly over guessing silently.
241
+
242
+ Think of js-tmpl as a function:
243
+
244
+ ```text
245
+ (input templates, data, config) → output files
246
+ ```
247
+
248
+ There is no hidden state, no lifecycle, and no side effects.
249
+ If you need orchestration, state, or interactivity, build it **around** js-tmpl — not inside it.
250
+
176
251
  ## CLI Reference
177
252
 
178
253
  ```bash
@@ -181,14 +256,16 @@ js-tmpl render [options]
181
256
 
182
257
  ### Options
183
258
 
184
- | Option | Description | Default |
185
- | ------------------------ | ----------------------- | -------------------- |
186
- | `-c, --values FILE` | Values file (YAML/JSON) | **Required** |
187
- | `-t, --template-dir DIR` | Template directory | `templates` |
188
- | `-o, --out DIR` | Output directory | `dist` |
189
- | `-p, --partials-dir DIR` | Partials directory | `templates.partials` |
190
- | `-x, --ext EXT` | Template extension | `.hbs` |
191
- | `--config-file FILE` | Explicit config file | Auto-discovered |
259
+ | Option | Description | Default |
260
+ | ------------------------ | --------------------------------------- | --------------- |
261
+ | `-c, --values FILE` | Values file (YAML/JSON) | **Required** |
262
+ | `-t, --template-dir DIR` | Template directory | `templates` |
263
+ | `-o, --out DIR` | Output directory | `dist` |
264
+ | `-p, --partials-dir DIR` | Partials directory | None (skipped) |
265
+ | `-x, --ext EXT` | Template extension | `.hbs` |
266
+ | `--config-file FILE` | Explicit config file | Auto-discovered |
267
+ | `--env-keys KEYS` | Comma-separated env var names to expose | None |
268
+ | `--env-prefix PREFIX` | Auto-include env vars with this prefix | None |
192
269
 
193
270
  ### Examples of Usage
194
271
 
@@ -202,79 +279,13 @@ js-tmpl render \
202
279
  --template-dir ./my-templates \
203
280
  --out ./output
204
281
 
205
- # Multi-environment
206
- NODE_ENV=production js-tmpl render --values prod-values.yaml
282
+ # Multi-environment (allowlist NODE_ENV to use it in templates)
283
+ NODE_ENV=production js-tmpl render --values prod-values.yaml --env-keys NODE_ENV
207
284
  ```
208
285
 
209
286
  ## Programmatic API
210
287
 
211
- See [docs/API.md](docs/API.md) for comprehensive API documentation.
212
-
213
- ### Import
214
-
215
- ```javascript
216
- import { resolveConfig, renderDirectory } from '@nci-gis/js-tmpl';
217
- ```
218
-
219
- ### resolveConfig(options)
220
-
221
- Resolves configuration with proper precedence.
222
-
223
- **Parameters:**
224
-
225
- - `options.valuesFile` (string, required) - Path to values file
226
- - `options.templateDir` (string) - Template directory path
227
- - `options.partialsDir` (string) - Partials directory path
228
- - `options.outDir` (string) - Output directory path
229
- - `options.extname` (string) - Template file extension
230
- - `options.configFile` (string) - Explicit config file path
231
-
232
- **Returns:** Resolved configuration object
233
-
234
- ### renderDirectory(config)
235
-
236
- Executes the rendering process.
237
-
238
- **Parameters:**
239
-
240
- - `config` (object) - Configuration from `resolveConfig`
241
-
242
- **Returns:** Promise that resolves when rendering completes
243
-
244
- ### Example
245
-
246
- ```javascript
247
- import { resolveConfig, renderDirectory } from '@nci-gis/js-tmpl';
248
-
249
- const config = resolveConfig({
250
- valuesFile: './values.yaml',
251
- templateDir: './templates',
252
- partialsDir: './partials',
253
- outDir: './dist'
254
- });
255
-
256
- await renderDirectory(config);
257
- console.log('✅ Rendering complete');
258
- ```
259
-
260
- ## Project Configuration
261
-
262
- Create `js-tmpl.config.yaml` in your project root:
263
-
264
- ```yaml
265
- templateDir: templates
266
- partialsDir: templates.partials
267
- outDir: dist
268
- extname: .hbs
269
- ```
270
-
271
- Auto-discovered config files (in order):
272
-
273
- 1. `js-tmpl.config.yaml`
274
- 2. `js-tmpl.config.yml`
275
- 3. `js-tmpl.config.json`
276
- 4. `config/js-tmpl.yaml`
277
- 5. `config/js-tmpl.json`
288
+ See [docs/API.md](docs/API.md) for the complete API reference — parameters, return types, config file format, and advanced usage.
278
289
 
279
290
  ## Examples
280
291
 
@@ -287,26 +298,14 @@ See [examples/yaml-templates/](examples/yaml-templates/) for a complete working
287
298
 
288
299
  ## Testing
289
300
 
290
- This project has comprehensive test coverage:
291
-
292
- - 156 tests
293
- - 99.8% line coverage
294
- - 99.7% branch coverage
301
+ This project has comprehensive automated test coverage across unit and integration suites.
302
+ Current coverage remains above 99% line coverage with high branch coverage as well.
295
303
 
296
304
  See [tests/README.md](tests/README.md) for testing documentation.
297
305
 
298
306
  ## Development Principles
299
307
 
300
- js-tmpl follows strict design principles:
301
-
302
- 1. **Engine First, CLI Second** - Programmatic API is primary
303
- 2. **Explicit Over Implicit** - No magic or hidden conventions
304
- 3. **Deterministic Over Clever** - Predictable behavior
305
- 4. **Separation of Concerns** - Each layer has one responsibility
306
- 5. **Composable Over Monolithic** - Small, focused functions
307
- 6. **Simple Over Feature-Rich** - Minimal API surface
308
-
309
- See [docs/PRINCIPLES.md](docs/PRINCIPLES.md) for details.
308
+ js-tmpl follows six core design principles — engine-first, explicit, deterministic, separated, composable, and simple. See [docs/PRINCIPLES.md](docs/PRINCIPLES.md) for the full philosophy.
310
309
 
311
310
  ## Roadmap
312
311
 
@@ -340,9 +339,18 @@ For security concerns, see [SECURITY.md](SECURITY.md).
340
339
 
341
340
  MIT © pasxd245
342
341
 
343
- ## Links
342
+ ## Learn More
343
+
344
+ ### 📚 Documentation
345
+
346
+ - **[📖 Documentation Hub](docs/ToC.md)** - Complete documentation index with learning paths
347
+ - [Design Principles](docs/PRINCIPLES.md) - Core philosophy guiding all decisions
348
+ - [Workflow Overview](docs/WORKFLOW.md) - Visual diagrams of the rendering pipeline
349
+ - [API Reference](docs/API.md) - Complete programmatic API documentation
350
+ - [Motivation](docs/Motivation.md) - Why js-tmpl exists and our vision
351
+
352
+ ### 🔗 Others
344
353
 
345
- - [Documentation](docs/)
346
- - [Examples](examples/)
347
- - [Issue Tracker](https://github.com/nci-gis/js-tmpl/issues)
348
- - [NPM Package](https://www.npmjs.com/package/@nci-gis/js-tmpl)
354
+ - [Examples](examples/) - Working examples and templates
355
+ - [Issue Tracker](https://github.com/nci-gis/js-tmpl/issues) - Report bugs or request features
356
+ - [NPM Package](https://www.npmjs.com/package/@nci-gis/js-tmpl) - Package registry
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nci-gis/js-tmpl",
3
- "version": "0.0.1-beta.1",
3
+ "version": "0.0.1",
4
4
  "description": "The pure JavaScript templating engine that uses handlebars.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -11,6 +11,24 @@
11
11
  "import": "./src/index.js"
12
12
  }
13
13
  },
14
+ "scripts": {
15
+ "prepare": "husky",
16
+ "test": "node --test $(find tests -name '*.test.js')",
17
+ "test:watch": "node --test --watch $(find tests -name '*.test.js')",
18
+ "test:coverage": "node --experimental-test-coverage --test $(find tests -name '*.test.js')",
19
+ "format": "prettier --write src/ tests/",
20
+ "format:check": "prettier --check src/ tests/",
21
+ "lint": "eslint src/ tests/",
22
+ "lint:fix": "eslint src/ tests/ --fix",
23
+ "build": "echo 'No build step required for pure JS library'",
24
+ "start": "node src/cli/main.js",
25
+ "help": "node src/cli/main.js --help",
26
+ "dev": "node src/cli/main.js",
27
+ "tool": "node src/cli/main.js",
28
+ "docs:check-links": "remark --frail --quiet *.md docs/*.md tests/README.md examples/**/*.md",
29
+ "docs:check-exports": "node scripts/check-doc-exports.js",
30
+ "docs:check": "pnpm docs:check-links && pnpm docs:check-exports"
31
+ },
14
32
  "keywords": [
15
33
  "js",
16
34
  "templating",
@@ -37,6 +55,7 @@
37
55
  "node": ">=20.0.0",
38
56
  "pnpm": ">=10.0.0"
39
57
  },
58
+ "packageManager": "pnpm@10.22.0",
40
59
  "dependencies": {
41
60
  "config": "^4.1.1",
42
61
  "handlebars": "^4.7.8",
@@ -44,19 +63,28 @@
44
63
  },
45
64
  "devDependencies": {
46
65
  "@eslint/js": "^9.39.2",
66
+ "@types/js-yaml": "^4.0.9",
67
+ "@types/node": "^25.5.2",
47
68
  "eslint": "^9.39.2",
48
- "eslint-plugin-simple-import-sort": "^12.1.1"
69
+ "eslint-plugin-simple-import-sort": "^12.1.1",
70
+ "husky": "^9.1.7",
71
+ "lint-staged": "^16.4.0",
72
+ "prettier": "^3.8.1",
73
+ "remark-cli": "^12.0.1",
74
+ "remark-validate-links": "^13.1.0",
75
+ "typescript": "^6.0.2"
49
76
  },
50
- "scripts": {
51
- "test": "node --test $(find tests -name '*.test.js')",
52
- "test:watch": "node --test --watch $(find tests -name '*.test.js')",
53
- "test:coverage": "node --experimental-test-coverage --test $(find tests -name '*.test.js')",
54
- "lint": "eslint src/ tests/",
55
- "lint:fix": "eslint src/ tests/ --fix",
56
- "build": "echo 'No build step required for pure JS library'",
57
- "start": "node src/cli/main.js",
58
- "help": "node src/cli/main.js --help",
59
- "dev": "node src/cli/main.js",
60
- "tool": "node src/cli/main.js"
77
+ "lint-staged": {
78
+ "src/**/*.js": [
79
+ "prettier --write",
80
+ "eslint --fix"
81
+ ],
82
+ "tests/**/*.js": [
83
+ "prettier --write",
84
+ "eslint --fix"
85
+ ],
86
+ "**/*.md": [
87
+ "prettier --write"
88
+ ]
61
89
  }
62
- }
90
+ }
package/src/cli/args.js CHANGED
@@ -1,49 +1,66 @@
1
1
  /**
2
2
  * Parse CLI arguments.
3
- *
3
+ *
4
4
  * @param {string[]} args
5
- * @returns {Record<string, any>}
5
+ * @returns {import('../types.js').CliArgs}
6
6
  */
7
7
  export function parseArgs(args) {
8
- const opts = { command: "render" };
8
+ /** @type {import('../types.js').CliArgs} */
9
+ const opts = { command: 'render' };
9
10
 
10
11
  let i = 0;
11
12
  while (i < args.length) {
12
13
  const a = args[i];
13
14
 
14
15
  switch (a) {
15
- case "render":
16
- opts.command = "render";
16
+ case '-h':
17
+ case '--help':
18
+ opts.command = 'help';
17
19
  break;
18
20
 
19
- case "-t":
20
- case "--template-dir":
21
+ case 'render':
22
+ opts.command = 'render';
23
+ break;
24
+
25
+ case '-t':
26
+ case '--template-dir':
21
27
  opts.templateDir = args[++i];
22
28
  break;
23
29
 
24
- case "-c":
25
- case "--values":
30
+ case '-c':
31
+ case '--values':
26
32
  opts.valuesFile = args[++i];
27
33
  break;
28
34
 
29
- case "-o":
30
- case "--out":
35
+ case '-o':
36
+ case '--out':
31
37
  opts.outDir = args[++i];
32
38
  break;
33
39
 
34
- case "-p":
35
- case "--partials-dir":
40
+ case '-p':
41
+ case '--partials-dir':
36
42
  opts.partialsDir = args[++i];
37
43
  break;
38
44
 
39
- case "--config-file":
45
+ case '--config-file':
40
46
  opts.configFile = args[++i];
41
47
  break;
42
48
 
43
- case "-x":
44
- case "--ext":
49
+ case '-x':
50
+ case '--ext':
45
51
  opts.extname = args[++i];
46
52
  break;
53
+
54
+ case '--env-keys':
55
+ opts.envKeys = args[++i]
56
+ .split(',')
57
+ .map((s) => s.trim())
58
+ .filter(Boolean);
59
+ break;
60
+
61
+ case '--env-prefix':
62
+ opts.envPrefix = args[++i];
63
+ break;
47
64
  }
48
65
  // next argument:
49
66
  i++;
package/src/cli/main.js CHANGED
@@ -1,14 +1,21 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { resolveConfig } from "../config/resolver.js";
4
- import { renderDirectory } from "../engine/renderDirectory.js";
5
- import { parseArgs } from "./args.js";
3
+ import { resolveConfig } from '../config/resolver.js';
4
+ import { renderDirectory } from '../engine/renderDirectory.js';
5
+ import { parseArgs } from './args.js';
6
+ import { USAGE } from './usage.js';
6
7
 
8
+ /** @returns {Promise<void>} */
7
9
  export async function main() {
8
10
  const cli = parseArgs(process.argv);
9
11
 
10
- if (cli.command !== "render") {
11
- console.error("Unknown command:", cli.command);
12
+ if (cli.command === 'help') {
13
+ console.log(USAGE);
14
+ return;
15
+ }
16
+
17
+ if (cli.command !== 'render') {
18
+ console.error('Unknown command:', cli.command);
12
19
  process.exit(1);
13
20
  }
14
21
 
@@ -16,13 +23,19 @@ export async function main() {
16
23
 
17
24
  await renderDirectory(cfg);
18
25
 
19
- console.log("✔ js-tmpl completed.");
26
+ console.log('✔ js-tmpl completed.');
20
27
  }
21
28
 
22
29
  // Execute main function if this file is run directly
23
- try {
24
- await main();
25
- } catch (error) {
26
- console.error("Error:", error);
27
- process.exit(1);
30
+ const isDirectRun =
31
+ process.argv[1] &&
32
+ import.meta.url.endsWith(process.argv[1].replaceAll('\\', '/'));
33
+
34
+ if (isDirectRun) {
35
+ try {
36
+ await main();
37
+ } catch (error) {
38
+ console.error('Error:', error);
39
+ process.exit(1);
40
+ }
28
41
  }
@@ -0,0 +1,16 @@
1
+ /** CLI usage text. */
2
+ export const USAGE = `Usage: js-tmpl [command] [options]
3
+
4
+ Commands:
5
+ render Render templates (default)
6
+
7
+ Options:
8
+ -t, --template-dir <dir> Template directory (default: templates)
9
+ -c, --values <file> Values file (YAML or JSON) [required]
10
+ -o, --out <dir> Output directory (default: dist)
11
+ -p, --partials-dir <dir> Partials directory
12
+ -x, --ext <ext> Template extension (default: .hbs)
13
+ --config-file <file> Project config file
14
+ --env-keys <keys> Comma-separated env var names to expose (default: none)
15
+ --env-prefix <prefix> Auto-include env vars with this prefix (e.g. JS_TMPL_)
16
+ -h, --help Show this help message`;
@@ -1,8 +1,10 @@
1
1
  export const DEFAULTS = {
2
- templateDir: "templates",
3
- partialsDir: "templates.partials",
4
- valuesDir: "templates.values",
5
- valuesFile: "",
6
- outDir: "dist",
7
- extname: ".hbs",
2
+ templateDir: 'templates',
3
+ partialsDir: '',
4
+ valuesDir: '',
5
+ valuesFile: '',
6
+ outDir: 'dist',
7
+ extname: '.hbs',
8
+ envKeys: [],
9
+ envPrefix: '',
8
10
  };
@@ -1,22 +1,30 @@
1
- import fs from "node:fs";
2
- import path from "node:path";
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
3
 
4
- import YAML from "js-yaml";
4
+ import YAML from 'js-yaml';
5
5
 
6
6
  /**
7
7
  * Load YAML or JSON values.
8
8
  *
9
9
  * @param {string} filePath Path to YAML or JSON file.
10
- * @returns {object} Parsed values.
10
+ * @returns {Record<string, unknown>} Parsed values.
11
11
  */
12
12
  export function loadYamlOrJson(filePath) {
13
- const raw = fs.readFileSync(filePath, "utf8");
13
+ // Check if file exists before attempting to read
14
+ if (!fs.existsSync(filePath)) {
15
+ throw new Error(
16
+ `Values file not found: ${filePath}\n` +
17
+ 'Check that the file exists and the path is correct.',
18
+ );
19
+ }
20
+
21
+ const raw = fs.readFileSync(filePath, 'utf8');
14
22
 
15
23
  if (/\.ya?ml$/i.test(filePath)) {
16
- return YAML.load(raw) || {};
24
+ return /** @type {Record<string, unknown>} */ (YAML.load(raw) || {});
17
25
  }
18
26
  if (/\.json$/i.test(filePath)) {
19
- return JSON.parse(raw);
27
+ return /** @type {Record<string, unknown>} */ (JSON.parse(raw));
20
28
  }
21
29
 
22
30
  throw new Error(`Unsupported values file: ${filePath}`);
@@ -27,30 +35,45 @@ export function loadYamlOrJson(filePath) {
27
35
  *
28
36
  * @param {string} cwd Current working directory.
29
37
  * @param {string} [explicitFile] Explicit config file path.
30
- * @returns {object|null} Parsed config object or null if not found.
38
+ * @returns {Record<string, unknown> | null} Parsed config object or null if not found.
31
39
  */
32
40
  export function loadProjectConfig(cwd, explicitFile) {
41
+ if (explicitFile) {
42
+ const abs = path.isAbsolute(explicitFile)
43
+ ? explicitFile
44
+ : path.join(cwd, explicitFile);
45
+ if (!fs.existsSync(abs)) {
46
+ throw new Error(
47
+ `Config file not found: ${abs}\n` +
48
+ 'The --config-file path was explicitly provided but does not exist.',
49
+ );
50
+ }
51
+ }
52
+
33
53
  const candidates = explicitFile
34
54
  ? [explicitFile]
35
55
  : [
36
- "js-tmpl.config.yaml",
37
- "js-tmpl.config.yml",
38
- "js-tmpl.config.json",
39
- path.join("config", "js-tmpl.yaml"),
40
- path.join("config", "js-tmpl.json"),
56
+ 'js-tmpl.config.yaml',
57
+ 'js-tmpl.config.yml',
58
+ 'js-tmpl.config.json',
59
+ path.join('config', 'js-tmpl.yaml'),
60
+ path.join('config', 'js-tmpl.json'),
41
61
  ];
42
62
 
43
63
  for (const rel of candidates) {
44
64
  const abs = path.isAbsolute(rel) ? rel : path.join(cwd, rel);
45
- if (!fs.existsSync(abs)) {continue;}
65
+ if (!fs.existsSync(abs)) {
66
+ continue;
67
+ }
46
68
 
47
- const raw = fs.readFileSync(abs, "utf8");
69
+ const raw = fs.readFileSync(abs, 'utf8');
48
70
 
49
- if (/\.ya?ml$/.test(abs)) {
50
- return YAML.load(raw) || {};
71
+ if (/\.ya?ml$/i.test(abs)) {
72
+ return /** @type {Record<string, unknown>} */ (YAML.load(raw) || {});
51
73
  }
52
- if (/\.json$/.test(abs)) {
53
- return JSON.parse(raw);
74
+
75
+ if (/\.json$/i.test(abs)) {
76
+ return /** @type {Record<string, unknown>} */ (JSON.parse(raw));
54
77
  }
55
78
  }
56
79
 
@@ -1,38 +1,42 @@
1
- import fs from "node:fs";
2
- import path from "node:path";
3
- import process from "node:process";
1
+ import path from 'node:path';
2
+ import process from 'node:process';
4
3
 
5
- import { DEFAULTS } from "./defaults.js";
6
- import { loadProjectConfig,loadYamlOrJson } from "./loader.js";
7
- import { buildView } from "./view.js";
4
+ import { DEFAULTS } from './defaults.js';
5
+ import { loadProjectConfig, loadYamlOrJson } from './loader.js';
6
+ import { buildView, pickEnv } from './view.js';
8
7
 
9
8
  /**
10
- * Lookup default values file in valuesDir.
11
- * Searches for: default.yaml, default.yml
12
- *
13
- * @param {string} valuesDir - Absolute path to values directory
14
- * @returns {string|null} - Path to default values file or null if not found
9
+ * Resolve valuesFile path based on valuesDir
10
+ * @param {string} valuesFile - The values file name or path
11
+ * @param {string} valuesDir - Values directory (may be empty string)
12
+ * @param {string} cwd - Current working directory
13
+ * @returns {string} Absolute path to values file
15
14
  */
16
- function lookupDefaultValuesFile(valuesDir) {
17
- const candidates = ["default.yaml", "default.yml"];
15
+ function resolveValuesFilePath(valuesFile, valuesDir, cwd) {
16
+ // If absolute, use as-is
17
+ if (path.isAbsolute(valuesFile)) {
18
+ return valuesFile;
19
+ }
18
20
 
19
- for (const filename of candidates) {
20
- const filepath = path.join(valuesDir, filename);
21
- if (fs.existsSync(filepath)) {
22
- return filepath;
23
- }
21
+ // If valuesDir is set (truthy), use it as base
22
+ if (valuesDir) {
23
+ const absoluteValuesDir = path.isAbsolute(valuesDir)
24
+ ? valuesDir
25
+ : path.join(cwd, valuesDir);
26
+ return path.join(absoluteValuesDir, valuesFile);
24
27
  }
25
28
 
26
- return null;
29
+ // Otherwise, resolve from cwd
30
+ return path.join(cwd, valuesFile);
27
31
  }
28
32
 
29
33
  /**
30
34
  * Resolve final config using:
31
35
  * defaults < projectConfig < cliArgs
32
36
  *
33
- * @param {object} cli - CLI arguments
34
- * @param {string} cwd - Current working directory
35
- * @returns {object} - Resolved configuration
37
+ * @param {import('../types.js').CliArgs} cli - CLI arguments
38
+ * @param {string} [cwd] - Current working directory
39
+ * @returns {import('../types.js').TemplateConfig} - Resolved configuration
36
40
  */
37
41
  export function resolveConfig(cli, cwd = process.cwd()) {
38
42
  const projectConfig = loadProjectConfig(cwd, cli.configFile);
@@ -43,28 +47,41 @@ export function resolveConfig(cli, cwd = process.cwd()) {
43
47
  ...cli,
44
48
  };
45
49
 
50
+ /** @param {string} p */
46
51
  const abs = (p) => (path.isAbsolute(p) ? p : path.join(cwd, p));
47
52
 
48
- // If valuesFile not specified, try to find default.yaml in valuesDir
49
- let valuesFile = mergedConfig.valuesFile;
50
- if (!valuesFile) {
51
- const valuesDir = abs(mergedConfig.valuesDir);
52
- valuesFile = lookupDefaultValuesFile(valuesDir);
53
-
54
- if (!valuesFile) {
55
- throw new Error(
56
- `Missing valuesFile. Use --values or create ${mergedConfig.valuesDir}/default.yaml`
57
- );
58
- }
53
+ // Validate valuesFile is provided
54
+ if (!mergedConfig.valuesFile) {
55
+ throw new Error(
56
+ 'Missing required configuration: valuesFile\n' +
57
+ 'Provide via:\n' +
58
+ ' - CLI: --values path/to/values.yaml\n' +
59
+ ' - Config: valuesFile: "path/to/values.yaml" in js-tmpl.config.yaml',
60
+ );
59
61
  }
60
62
 
61
- const values = loadYamlOrJson(abs(valuesFile));
63
+ // Resolve path - simple logic based on valuesDir presence
64
+ const valuesFilePath = resolveValuesFilePath(
65
+ mergedConfig.valuesFile,
66
+ mergedConfig.valuesDir,
67
+ cwd,
68
+ );
69
+
70
+ const values = loadYamlOrJson(valuesFilePath);
71
+
72
+ const hasEnvConfig = mergedConfig.envKeys?.length || mergedConfig.envPrefix;
73
+ const env = hasEnvConfig
74
+ ? pickEnv({
75
+ keys: mergedConfig.envKeys || [],
76
+ prefix: mergedConfig.envPrefix || '',
77
+ })
78
+ : {};
62
79
 
63
80
  return {
64
81
  templateDir: abs(mergedConfig.templateDir),
65
- partialsDir: abs(mergedConfig.partialsDir),
82
+ partialsDir: mergedConfig.partialsDir ? abs(mergedConfig.partialsDir) : '',
66
83
  outDir: abs(mergedConfig.outDir),
67
84
  extname: mergedConfig.extname,
68
- view: buildView(values),
85
+ view: buildView(values, env),
69
86
  };
70
87
  }
@@ -1,6 +1,53 @@
1
- export function buildView(values, env = process.env) {
1
+ /**
2
+ * Build the view object by merging values with environment data.
3
+ *
4
+ * **Reserved key:** `env` is always set to the provided environment object.
5
+ * If `values` contains a top-level `env` key, a warning is logged and
6
+ * the key is overwritten.
7
+ *
8
+ * @param {Record<string, unknown>} values
9
+ * @param {Record<string, string>} [env]
10
+ * @returns {Record<string, unknown>}
11
+ */
12
+ export function buildView(values, env = {}) {
13
+ if ('env' in values) {
14
+ console.warn(
15
+ 'Warning: "env" is a reserved key in js-tmpl and will be overwritten.\n' +
16
+ 'Rename the "env" key in your values file to avoid this.',
17
+ );
18
+ }
2
19
  return {
3
20
  ...values,
4
21
  env,
5
22
  };
6
23
  }
24
+
25
+ /**
26
+ * Pick environment variables by explicit keys and/or prefix.
27
+ *
28
+ * @param {object} options
29
+ * @param {string[]} options.keys - Explicit key names to include.
30
+ * @param {string} options.prefix - Include all vars starting with this prefix.
31
+ * @param {Record<string, string | undefined>} [source] - Env source (default: process.env).
32
+ * @returns {Record<string, string>}
33
+ */
34
+ export function pickEnv({ keys = [], prefix = '' }, source = process.env) {
35
+ /** @type {Record<string, string>} */
36
+ const result = {};
37
+
38
+ for (const k of keys) {
39
+ if (k in source) {
40
+ result[k] = /** @type {string} */ (source[k]);
41
+ }
42
+ }
43
+
44
+ if (prefix) {
45
+ for (const k of Object.keys(source)) {
46
+ if (k.startsWith(prefix)) {
47
+ result[k] = /** @type {string} */ (source[k]);
48
+ }
49
+ }
50
+ }
51
+
52
+ return result;
53
+ }
@@ -1,12 +1,16 @@
1
- import fs from "node:fs/promises";
1
+ import fs from 'node:fs/promises';
2
2
 
3
- import Handlebars from "handlebars";
3
+ import Handlebars from 'handlebars';
4
4
 
5
5
  /**
6
6
  * Render template file with Handlebars & view object.
7
+ * @param {string} filePath
8
+ * @param {Record<string, unknown>} view
9
+ * @param {typeof Handlebars} [hbs] - Scoped Handlebars instance; falls back to global
10
+ * @returns {Promise<string>}
7
11
  */
8
- export async function renderContent(filePath, view) {
9
- const raw = await fs.readFile(filePath, "utf8");
10
- const compile = Handlebars.compile(raw);
12
+ export async function renderContent(filePath, view, hbs) {
13
+ const raw = await fs.readFile(filePath, 'utf8');
14
+ const compile = (hbs || Handlebars).compile(raw);
11
15
  return compile(view);
12
16
  }
@@ -1,41 +1,117 @@
1
- import fs from "node:fs/promises";
2
- import path from "node:path";
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
3
 
4
- import Handlebars from "handlebars";
4
+ const VALID_SEGMENT = /^\w+$/;
5
5
 
6
6
  /**
7
- * Register partials from a directory.
8
- * - root partials use prefix "_"
9
- * - group partials under "@group"
7
+ * Validate a partial name segment (directory or file basename).
8
+ * @param {string[]} segments
9
+ * @param {string} filePath
10
10
  */
11
- export async function registerPartials(partialsDir, ext = ".hbs") {
12
- const entries = await fs.readdir(partialsDir, { withFileTypes: true });
11
+ function validateSegments(segments, filePath) {
12
+ if (!VALID_SEGMENT.test(segments.join(''))) {
13
+ throw new Error(
14
+ `Invalid partial name segment '${segments.join('>')}' in ${filePath} — only alphanumeric and underscore allowed`,
15
+ );
16
+ }
17
+ }
18
+
19
+ /**
20
+ * Derive a partial entry from a file path.
21
+ *
22
+ * @param {string} partialsDir - Root partials directory
23
+ * @param {string} ext - Template extension (e.g. ".hbs")
24
+ * @param {string} filePath - Relative path from partialsDir
25
+ * @param {boolean} isFlat - If true, register by filename only; otherwise namespace by path
26
+ * @returns {{ name: string, source: string }}
27
+ */
28
+ function processPartialFile(partialsDir, ext, filePath, isFlat) {
29
+ const abs = path.join(partialsDir, filePath);
30
+
31
+ // Flat by filename: "name.hbs" → "name"
32
+ if (isFlat) {
33
+ const name = path.basename(filePath, ext);
34
+ validateSegments([name], abs);
35
+ return { name, source: abs };
36
+ }
37
+
38
+ // Namespace by path, e.g.:
39
+ // - "dir/name.hbs" → "dir.name"
40
+ // - "dir/sub/name.hbs" → "dir.sub.name"
41
+ // - "name.hbs" → "name" (no nesting).
42
+ const segments = filePath.slice(0, -ext.length).split(path.sep);
43
+ validateSegments(segments, abs);
44
+ return { name: segments.join('.'), source: abs };
45
+ }
46
+
47
+ /**
48
+ * Scan partialsDir recursively and collect all partial entries.
49
+ * @param {string} partialsDir
50
+ * @param {string} ext
51
+ * @returns {Promise<Array<{ name: string, source: string }>>}
52
+ */
53
+ async function scanPartialFiles(partialsDir, ext) {
54
+ const allFiles = await fs.readdir(partialsDir, { recursive: true });
55
+
56
+ return allFiles
57
+ .filter((f) => f.endsWith(ext))
58
+ .map((f) => {
59
+ const isFlat = f.startsWith('@');
60
+ return processPartialFile(partialsDir, ext, f, isFlat);
61
+ });
62
+ }
63
+
64
+ /**
65
+ * Check for duplicate partial names and throw if found.
66
+ * @param {Array<{ name: string, source: string }>} entries
67
+ * @param {string} partialsDir
68
+ */
69
+ function checkDuplicates(entries, partialsDir) {
70
+ /** @type {Map<string, string>} */
71
+ const seen = new Map();
13
72
 
14
73
  for (const entry of entries) {
15
- const abs = path.join(partialsDir, entry.name);
16
-
17
- if (entry.isDirectory()) {
18
- // handle namespacing
19
- if (entry.name.startsWith("@")) {
20
- const group = entry.name.slice(1);
21
- const groupFiles = await fs.readdir(abs);
22
-
23
- for (const f of groupFiles) {
24
- if (!f.endsWith(ext)) {continue;}
25
- const key = path.basename(f, ext);
26
- const name = `${group}.${key}`;
27
- const content = await fs.readFile(path.join(abs, f), "utf8");
28
- Handlebars.registerPartial(name, content);
29
- }
30
- }
31
- continue;
74
+ const existing = seen.get(entry.name);
75
+ if (existing) {
76
+ const rel1 = path.relative(partialsDir, existing);
77
+ const rel2 = path.relative(partialsDir, entry.source);
78
+ throw new Error(
79
+ `Duplicate partial name '${entry.name}' — registered by both:\n` +
80
+ ` - ${rel1}\n` +
81
+ ` - ${rel2}\n` +
82
+ `Use namespaced directories to avoid collisions.`,
83
+ );
32
84
  }
85
+ seen.set(entry.name, entry.source);
86
+ }
87
+ }
33
88
 
34
- // root partial
35
- if (entry.name.startsWith("_") && entry.name.endsWith(ext)) {
36
- const content = await fs.readFile(abs, "utf8");
37
- const key = entry.name.slice(1, -ext.length);
38
- Handlebars.registerPartial(key, content);
39
- }
89
+ /**
90
+ * Register partials from a directory onto a Handlebars instance.
91
+ *
92
+ * Naming conventions:
93
+ * - `name.hbs` in root → "name"
94
+ * - `dir/name.hbs` → "dir.name" (namespaced by directory path)
95
+ * - `@dir/` at root → flatten entire subtree (filename only)
96
+ *
97
+ * Throws on duplicate partial names or invalid name segments.
98
+ * Skips silently if partialsDir is falsy. Throws if the directory does not exist.
99
+ *
100
+ * @param {string} partialsDir - Path to partials directory
101
+ * @param {string} ext - Template file extension (e.g. ".hbs")
102
+ * @param {import('handlebars')} hbs - Handlebars instance to register partials on
103
+ */
104
+ export async function registerPartials(partialsDir, ext, hbs) {
105
+ if (!partialsDir) {
106
+ return;
107
+ }
108
+
109
+ const allPartials = await scanPartialFiles(partialsDir, ext);
110
+
111
+ checkDuplicates(allPartials, partialsDir);
112
+
113
+ for (const p of allPartials) {
114
+ const content = await fs.readFile(p.source, 'utf8');
115
+ hbs.registerPartial(p.name, content);
40
116
  }
41
117
  }
@@ -1,9 +1,12 @@
1
- import path from "node:path";
1
+ import path from 'node:path';
2
2
 
3
- import { getNested } from "../utils/object.js";
3
+ import { getNested } from '../utils/object.js';
4
4
 
5
5
  /**
6
6
  * Render `${var}` placeholders in each segment of the relPath.
7
+ * @param {string} relPath
8
+ * @param {Record<string, unknown>} view
9
+ * @returns {string}
7
10
  */
8
11
  export function renderPath(relPath, view) {
9
12
  const segments = relPath.split(path.sep);
@@ -12,8 +15,8 @@ export function renderPath(relPath, view) {
12
15
  //NOSONAR -- ignore S5842: Regular expression is safe here
13
16
  seg.replace(/\$\{([^}]+)\}/g, (_, expr) => {
14
17
  const v = getNested(view, expr.trim());
15
- return String(v ?? "");
16
- })
18
+ return String(v ?? '');
19
+ }),
17
20
  );
18
21
 
19
22
  return path.join(...rendered);
@@ -1,18 +1,24 @@
1
- import path from "node:path";
1
+ import path from 'node:path';
2
2
 
3
- import { ensureDir, writeFileSafe } from "../utils/fs.js";
4
- import { renderContent } from "./contentRenderer.js";
5
- import { registerPartials } from "./partials.js";
6
- import { renderPath } from "./pathRenderer.js";
7
- import { walkTemplateTree } from "./treeWalker.js";
3
+ import Handlebars from 'handlebars';
4
+
5
+ import { ensureDir, writeFileSafe } from '../utils/fs.js';
6
+ import { renderContent } from './contentRenderer.js';
7
+ import { registerPartials } from './partials.js';
8
+ import { renderPath } from './pathRenderer.js';
9
+ import { walkTemplateTree } from './treeWalker.js';
8
10
 
9
11
  /**
10
12
  * Main rendering orchestrator.
13
+ * @param {import('../types.js').TemplateConfig} cfg
14
+ * @param {typeof import('handlebars')} [hbs] - Optional Handlebars instance (creates an isolated one if omitted)
15
+ * @returns {Promise<void>}
11
16
  */
12
- export async function renderDirectory(cfg) {
17
+ export async function renderDirectory(cfg, hbs) {
13
18
  const { templateDir, partialsDir, outDir, view, extname } = cfg;
14
19
 
15
- await registerPartials(partialsDir, extname);
20
+ hbs = hbs || Handlebars.create();
21
+ await registerPartials(partialsDir, extname, hbs);
16
22
 
17
23
  const files = await walkTemplateTree(templateDir, extname);
18
24
 
@@ -20,10 +26,10 @@ export async function renderDirectory(cfg) {
20
26
  const relRendered = renderPath(file.relPath, view);
21
27
  const target = path.join(
22
28
  outDir,
23
- relRendered.replace(new RegExp(`${extname}$`), "")
29
+ relRendered.replace(new RegExp(`${extname}$`), ''),
24
30
  );
25
31
 
26
- const content = await renderContent(file.absPath, view);
32
+ const content = await renderContent(file.absPath, view, hbs);
27
33
 
28
34
  await ensureDir(path.dirname(target));
29
35
  await writeFileSafe(target, content);
@@ -1,22 +1,28 @@
1
- import fs from "node:fs/promises";
2
- import path from "node:path";
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
3
 
4
4
  /**
5
5
  * BFS async folder walker.
6
+ * @param {string} rootDir
7
+ * @param {string} [ext]
8
+ * @param {Array<string | RegExp>} [ignore]
9
+ * @returns {Promise<import('../types.js').TemplateFile[]>}
6
10
  */
7
- export async function walkTemplateTree(rootDir, ext = ".hbs", ignore = []) {
11
+ export async function walkTemplateTree(rootDir, ext = '.hbs', ignore = []) {
8
12
  const results = [];
9
- const queue = [""];
13
+ const queue = [''];
10
14
 
11
15
  while (queue.length) {
12
- const rel = queue.shift();
16
+ const rel = /** @type {string} */ (queue.shift());
13
17
  const abs = path.join(rootDir, rel);
14
18
  const stat = await fs.stat(abs);
15
19
 
16
20
  if (stat.isDirectory()) {
17
- const items = await fs.readdir(abs);
21
+ const items = (await fs.readdir(abs)).sort();
18
22
  for (const name of items) {
19
- if (ignore.some((i) => matchIgnore(name, i))) {continue;}
23
+ if (ignore.some((i) => matchIgnore(name, i))) {
24
+ continue;
25
+ }
20
26
  queue.push(rel ? path.join(rel, name) : name);
21
27
  }
22
28
  } else if (path.extname(abs) === ext) {
@@ -27,6 +33,11 @@ export async function walkTemplateTree(rootDir, ext = ".hbs", ignore = []) {
27
33
  return results;
28
34
  }
29
35
 
36
+ /**
37
+ * @param {string} name
38
+ * @param {string | RegExp} rule
39
+ * @returns {boolean}
40
+ */
30
41
  function matchIgnore(name, rule) {
31
42
  return rule instanceof RegExp ? rule.test(name) : rule === name;
32
43
  }
package/src/index.js CHANGED
@@ -1,2 +1,2 @@
1
- export * from "./config/resolver.js";
2
- export * from "./engine/renderDirectory.js";
1
+ export * from './config/resolver.js';
2
+ export * from './engine/renderDirectory.js';
package/src/types.js ADDED
@@ -0,0 +1,30 @@
1
+ /**
2
+ * @typedef {object} CliArgs
3
+ * @property {string} [command]
4
+ * @property {string} [templateDir]
5
+ * @property {string} [valuesFile]
6
+ * @property {string} [outDir]
7
+ * @property {string} [partialsDir]
8
+ * @property {string} [configFile]
9
+ * @property {string} [extname]
10
+ * @property {string} [valuesDir]
11
+ * @property {string[]} [envKeys]
12
+ * @property {string} [envPrefix]
13
+ */
14
+
15
+ /**
16
+ * @typedef {object} TemplateConfig
17
+ * @property {string} templateDir
18
+ * @property {string} partialsDir
19
+ * @property {string} outDir
20
+ * @property {string} extname
21
+ * @property {Record<string, unknown>} view
22
+ */
23
+
24
+ /**
25
+ * @typedef {object} TemplateFile
26
+ * @property {string} absPath
27
+ * @property {string} relPath
28
+ */
29
+
30
+ export {}; //NOSONAR
package/src/utils/fs.js CHANGED
@@ -1,5 +1,5 @@
1
- import fs from "node:fs/promises";
2
- import path from "node:path";
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
3
 
4
4
  /** Safe mkdir -p
5
5
  * @param {string} dir Directory path to create
@@ -13,7 +13,7 @@ export async function ensureDir(dir) {
13
13
  * @param {string} content Content to write
14
14
  */
15
15
  export async function writeFileSafe(file, content) {
16
- await fs.writeFile(file, content, "utf8");
16
+ await fs.writeFile(file, content, 'utf8');
17
17
  }
18
18
 
19
19
  /** Resolve path relative to cwd
@@ -35,7 +35,8 @@ export function resolvePath(p, cwd = process.cwd()) {
35
35
  * @returns {string} Absolute path.
36
36
  */
37
37
  export function safeResolvePath(...segments) {
38
- const isAbsolute = segments ? segments[0] && path.isAbsolute(segments[0]) : false;
38
+ const isAbsolute =
39
+ segments.length > 0 && segments[0] && path.isAbsolute(segments[0]);
39
40
  if (isAbsolute) {
40
41
  return path.resolve(...segments);
41
42
  }
@@ -1,10 +1,15 @@
1
1
  /**
2
2
  * Retrieve nested value: getNested(obj, "a.b.c")
3
+ * @param {Record<string, unknown>} obj
4
+ * @param {string} key
5
+ * @returns {unknown}
3
6
  */
4
7
  export function getNested(obj, key) {
5
- console.log("getNested", obj, key);
6
-
7
8
  return key
8
- .split(".")
9
- .reduce((acc, k) => (acc?.[k] ?? undefined), obj);
9
+ .split('.')
10
+ .reduce(
11
+ (/** @type {unknown} */ acc, /** @type {string} */ k) =>
12
+ /** @type {Record<string, unknown>} */ (acc)?.[k] ?? undefined,
13
+ obj,
14
+ );
10
15
  }