@nci-gis/js-tmpl 0.0.1-beta.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 nci-gis
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,348 @@
1
+ # js-tmpl
2
+
3
+ > A lightweight, deterministic file templating engine built on Handlebars
4
+
5
+ [![npm version](https://img.shields.io/npm/v/@nci-gis/js-tmpl.svg)](https://www.npmjs.com/package/@nci-gis/js-tmpl)
6
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
7
+
8
+ ## What is js-tmpl?
9
+
10
+ js-tmpl is a **pure transformation layer** that generates files and directory structures from templates with predictable, explicit behavior.
11
+
12
+ It's designed for:
13
+
14
+ - DevOps configuration management
15
+ - Code scaffolding and generation
16
+ - Multi-environment deployments
17
+ - Project template systems
18
+
19
+ **Not a framework. Not a workflow tool. Just a focused rendering engine.**
20
+
21
+ ## Why js-tmpl?
22
+
23
+ Most template tools are either too simple (basic string replacement) or too complex (opinionated frameworks). js-tmpl fills the gap:
24
+
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
30
+
31
+ See [docs/00-Motivation.md](docs/00-Motivation.md) for the full story.
32
+
33
+ ## Features
34
+
35
+ - 🎯 **Dynamic File Paths** - Use `${var}` placeholders in paths and filenames
36
+ - 🧩 **Handlebars Templates** - Full Handlebars feature set (loops, conditionals, helpers)
37
+ - 📦 **Partial System** - Reusable components with root and namespaced partials
38
+ - ⚙️ **Flexible Configuration** - CLI args > project config > defaults
39
+ - 🌲 **BFS Tree Walking** - Async, non-blocking template discovery
40
+ - 🔒 **No Global State** - Isolated render passes, no pollution
41
+ - 📝 **YAML/JSON Support** - Load values from either format
42
+
43
+ ## Installation
44
+
45
+ ```bash
46
+ npm install @nci-gis/js-tmpl
47
+ ```
48
+
49
+ **Requirements:** Node.js ≥ 20
50
+
51
+ ## Quick Start
52
+
53
+ ### 1. Create a values file
54
+
55
+ ```yaml
56
+ # values.yaml
57
+ project:
58
+ name: my-app
59
+ version: 1.0.0
60
+
61
+ config:
62
+ port: 3000
63
+ host: localhost
64
+ ```
65
+
66
+ ### 2. Create templates
67
+
68
+ ```text
69
+ templates/
70
+ ├── ${project.name}/
71
+ │ └── config.json.hbs
72
+ └── README.md.hbs
73
+ ```
74
+
75
+ **Template content** (`templates/${project.name}/config.json.hbs`):
76
+
77
+ ```handlebars
78
+ {
79
+ "name": "{{project.name}}",
80
+ "version": "{{project.version}}",
81
+ "server": {
82
+ "port": {{config.port}},
83
+ "host": "{{config.host}}"
84
+ }
85
+ }
86
+ ```
87
+
88
+ ### 3. Render templates
89
+
90
+ **CLI:**
91
+
92
+ ```bash
93
+ js-tmpl render --values values.yaml
94
+ ```
95
+
96
+ **Programmatic API:**
97
+
98
+ ```javascript
99
+ import { resolveConfig, renderDirectory } from '@nci-gis/js-tmpl';
100
+
101
+ const config = resolveConfig({
102
+ valuesFile: './values.yaml',
103
+ templateDir: './templates',
104
+ outDir: './dist'
105
+ });
106
+
107
+ await renderDirectory(config);
108
+ ```
109
+
110
+ ### 4. Get output
111
+
112
+ ```text
113
+ dist/
114
+ ├── my-app/
115
+ │ └── config.json
116
+ └── README.md
117
+ ```
118
+
119
+ ## Core Concepts
120
+
121
+ ### Configuration Precedence
122
+
123
+ ```text
124
+ CLI arguments
125
+ > Project config file (js-tmpl.config.yaml)
126
+ > Internal defaults
127
+ ```
128
+
129
+ ### View Model
130
+
131
+ Templates receive a view object:
132
+
133
+ ```javascript
134
+ {
135
+ ...valuesFromFile, // Your YAML/JSON data
136
+ env: process.env // Environment variables
137
+ }
138
+ ```
139
+
140
+ Access in templates:
141
+
142
+ ```handlebars
143
+ {{project.name}}
144
+ {{env.NODE_ENV}}
145
+ ```
146
+
147
+ ### Path Rendering
148
+
149
+ Use `${var}` in file/folder paths:
150
+
151
+ ```text
152
+ templates/
153
+ └── ${env.NODE_ENV}/
154
+ └── config-${project.name}.yaml.hbs
155
+
156
+ → dist/production/config-my-app.yaml
157
+ ```
158
+
159
+ ### Partial System
160
+
161
+ **Root partials** (`_name.hbs`):
162
+
163
+ ```text
164
+ templates.partials/
165
+ └── _header.hbs → {{> header}}
166
+ ```
167
+
168
+ **Namespaced partials** (`@group/name.hbs`):
169
+
170
+ ```text
171
+ templates.partials/
172
+ └── @common/
173
+ └── metadata.hbs → {{> common.metadata}}
174
+ ```
175
+
176
+ ## CLI Reference
177
+
178
+ ```bash
179
+ js-tmpl render [options]
180
+ ```
181
+
182
+ ### Options
183
+
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 |
192
+
193
+ ### Examples of Usage
194
+
195
+ ```bash
196
+ # Basic usage
197
+ js-tmpl render --values data.yaml
198
+
199
+ # Custom directories
200
+ js-tmpl render \
201
+ --values data.yaml \
202
+ --template-dir ./my-templates \
203
+ --out ./output
204
+
205
+ # Multi-environment
206
+ NODE_ENV=production js-tmpl render --values prod-values.yaml
207
+ ```
208
+
209
+ ## Programmatic API
210
+
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`
278
+
279
+ ## Examples
280
+
281
+ See [examples/yaml-templates/](examples/yaml-templates/) for a complete working example demonstrating:
282
+
283
+ - Dynamic file paths with `${env.NODE_ENV}`
284
+ - Handlebars features (loops, conditionals)
285
+ - Root and namespaced partials
286
+ - Multi-format output (YAML, Markdown)
287
+
288
+ ## Testing
289
+
290
+ This project has comprehensive test coverage:
291
+
292
+ - 156 tests
293
+ - 99.8% line coverage
294
+ - 99.7% branch coverage
295
+
296
+ See [tests/README.md](tests/README.md) for testing documentation.
297
+
298
+ ## Development Principles
299
+
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.
310
+
311
+ ## Roadmap
312
+
313
+ See [ROADMAP.md](ROADMAP.md) for planned features and improvements.
314
+
315
+ See [CHANGELOG.md](CHANGELOG.md) for version history.
316
+
317
+ ## Contributing
318
+
319
+ We welcome contributions! Please read [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
320
+ For maintainers: See [CONTRIBUTING.md#release-process](CONTRIBUTING.md#release-process) for release instructions.
321
+
322
+ ## Installing Pre-release Versions
323
+
324
+ ```bash
325
+ # Stable (latest)
326
+ npm install @nci-gis/js-tmpl
327
+
328
+ # Beta
329
+ npm install @nci-gis/js-tmpl@beta
330
+
331
+ # Alpha
332
+ npm install @nci-gis/js-tmpl@alpha
333
+ ```
334
+
335
+ ## Security
336
+
337
+ For security concerns, see [SECURITY.md](SECURITY.md).
338
+
339
+ ## License
340
+
341
+ MIT © pasxd245
342
+
343
+ ## Links
344
+
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)
package/bin/js-tmpl ADDED
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env bash
2
+ # CLI entry point for js-tmpl
3
+ node "$(dirname "$0")/../src/cli/main.js" "$@"
package/package.json ADDED
@@ -0,0 +1,62 @@
1
+ {
2
+ "name": "@nci-gis/js-tmpl",
3
+ "version": "0.0.1-beta.1",
4
+ "description": "The pure JavaScript templating engine that uses handlebars.",
5
+ "type": "module",
6
+ "bin": {
7
+ "js-tmpl": "bin/js-tmpl"
8
+ },
9
+ "exports": {
10
+ ".": {
11
+ "import": "./src/index.js"
12
+ }
13
+ },
14
+ "keywords": [
15
+ "js",
16
+ "templating",
17
+ "handlebars",
18
+ "utils"
19
+ ],
20
+ "author": "pasxd245",
21
+ "license": "MIT",
22
+ "repository": {
23
+ "type": "git",
24
+ "url": "git+https://github.com/nci-gis/js-tmpl.git"
25
+ },
26
+ "homepage": "https://github.com/nci-gis/js-tmpl#readme",
27
+ "bugs": {
28
+ "url": "https://github.com/nci-gis/js-tmpl/issues"
29
+ },
30
+ "files": [
31
+ "bin/",
32
+ "src/",
33
+ "LICENSE",
34
+ "README.md"
35
+ ],
36
+ "engines": {
37
+ "node": ">=20.0.0",
38
+ "pnpm": ">=10.0.0"
39
+ },
40
+ "dependencies": {
41
+ "config": "^4.1.1",
42
+ "handlebars": "^4.7.8",
43
+ "js-yaml": "^4.1.1"
44
+ },
45
+ "devDependencies": {
46
+ "@eslint/js": "^9.39.2",
47
+ "eslint": "^9.39.2",
48
+ "eslint-plugin-simple-import-sort": "^12.1.1"
49
+ },
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"
61
+ }
62
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Parse CLI arguments.
3
+ *
4
+ * @param {string[]} args
5
+ * @returns {Record<string, any>}
6
+ */
7
+ export function parseArgs(args) {
8
+ const opts = { command: "render" };
9
+
10
+ let i = 0;
11
+ while (i < args.length) {
12
+ const a = args[i];
13
+
14
+ switch (a) {
15
+ case "render":
16
+ opts.command = "render";
17
+ break;
18
+
19
+ case "-t":
20
+ case "--template-dir":
21
+ opts.templateDir = args[++i];
22
+ break;
23
+
24
+ case "-c":
25
+ case "--values":
26
+ opts.valuesFile = args[++i];
27
+ break;
28
+
29
+ case "-o":
30
+ case "--out":
31
+ opts.outDir = args[++i];
32
+ break;
33
+
34
+ case "-p":
35
+ case "--partials-dir":
36
+ opts.partialsDir = args[++i];
37
+ break;
38
+
39
+ case "--config-file":
40
+ opts.configFile = args[++i];
41
+ break;
42
+
43
+ case "-x":
44
+ case "--ext":
45
+ opts.extname = args[++i];
46
+ break;
47
+ }
48
+ // next argument:
49
+ i++;
50
+ }
51
+
52
+ return opts;
53
+ }
@@ -0,0 +1,28 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { resolveConfig } from "../config/resolver.js";
4
+ import { renderDirectory } from "../engine/renderDirectory.js";
5
+ import { parseArgs } from "./args.js";
6
+
7
+ export async function main() {
8
+ const cli = parseArgs(process.argv);
9
+
10
+ if (cli.command !== "render") {
11
+ console.error("Unknown command:", cli.command);
12
+ process.exit(1);
13
+ }
14
+
15
+ const cfg = resolveConfig(cli);
16
+
17
+ await renderDirectory(cfg);
18
+
19
+ console.log("✔ js-tmpl completed.");
20
+ }
21
+
22
+ // 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);
28
+ }
@@ -0,0 +1,8 @@
1
+ export const DEFAULTS = {
2
+ templateDir: "templates",
3
+ partialsDir: "templates.partials",
4
+ valuesDir: "templates.values",
5
+ valuesFile: "",
6
+ outDir: "dist",
7
+ extname: ".hbs",
8
+ };
@@ -0,0 +1,58 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ import YAML from "js-yaml";
5
+
6
+ /**
7
+ * Load YAML or JSON values.
8
+ *
9
+ * @param {string} filePath Path to YAML or JSON file.
10
+ * @returns {object} Parsed values.
11
+ */
12
+ export function loadYamlOrJson(filePath) {
13
+ const raw = fs.readFileSync(filePath, "utf8");
14
+
15
+ if (/\.ya?ml$/i.test(filePath)) {
16
+ return YAML.load(raw) || {};
17
+ }
18
+ if (/\.json$/i.test(filePath)) {
19
+ return JSON.parse(raw);
20
+ }
21
+
22
+ throw new Error(`Unsupported values file: ${filePath}`);
23
+ }
24
+
25
+ /**
26
+ * Load js-tmpl project config from known locations.
27
+ *
28
+ * @param {string} cwd Current working directory.
29
+ * @param {string} [explicitFile] Explicit config file path.
30
+ * @returns {object|null} Parsed config object or null if not found.
31
+ */
32
+ export function loadProjectConfig(cwd, explicitFile) {
33
+ const candidates = explicitFile
34
+ ? [explicitFile]
35
+ : [
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"),
41
+ ];
42
+
43
+ for (const rel of candidates) {
44
+ const abs = path.isAbsolute(rel) ? rel : path.join(cwd, rel);
45
+ if (!fs.existsSync(abs)) {continue;}
46
+
47
+ const raw = fs.readFileSync(abs, "utf8");
48
+
49
+ if (/\.ya?ml$/.test(abs)) {
50
+ return YAML.load(raw) || {};
51
+ }
52
+ if (/\.json$/.test(abs)) {
53
+ return JSON.parse(raw);
54
+ }
55
+ }
56
+
57
+ return null;
58
+ }
@@ -0,0 +1,70 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import process from "node:process";
4
+
5
+ import { DEFAULTS } from "./defaults.js";
6
+ import { loadProjectConfig,loadYamlOrJson } from "./loader.js";
7
+ import { buildView } from "./view.js";
8
+
9
+ /**
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
15
+ */
16
+ function lookupDefaultValuesFile(valuesDir) {
17
+ const candidates = ["default.yaml", "default.yml"];
18
+
19
+ for (const filename of candidates) {
20
+ const filepath = path.join(valuesDir, filename);
21
+ if (fs.existsSync(filepath)) {
22
+ return filepath;
23
+ }
24
+ }
25
+
26
+ return null;
27
+ }
28
+
29
+ /**
30
+ * Resolve final config using:
31
+ * defaults < projectConfig < cliArgs
32
+ *
33
+ * @param {object} cli - CLI arguments
34
+ * @param {string} cwd - Current working directory
35
+ * @returns {object} - Resolved configuration
36
+ */
37
+ export function resolveConfig(cli, cwd = process.cwd()) {
38
+ const projectConfig = loadProjectConfig(cwd, cli.configFile);
39
+
40
+ const mergedConfig = {
41
+ ...DEFAULTS,
42
+ ...projectConfig,
43
+ ...cli,
44
+ };
45
+
46
+ const abs = (p) => (path.isAbsolute(p) ? p : path.join(cwd, p));
47
+
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
+ }
59
+ }
60
+
61
+ const values = loadYamlOrJson(abs(valuesFile));
62
+
63
+ return {
64
+ templateDir: abs(mergedConfig.templateDir),
65
+ partialsDir: abs(mergedConfig.partialsDir),
66
+ outDir: abs(mergedConfig.outDir),
67
+ extname: mergedConfig.extname,
68
+ view: buildView(values),
69
+ };
70
+ }
@@ -0,0 +1,6 @@
1
+ export function buildView(values, env = process.env) {
2
+ return {
3
+ ...values,
4
+ env,
5
+ };
6
+ }
@@ -0,0 +1,12 @@
1
+ import fs from "node:fs/promises";
2
+
3
+ import Handlebars from "handlebars";
4
+
5
+ /**
6
+ * Render template file with Handlebars & view object.
7
+ */
8
+ export async function renderContent(filePath, view) {
9
+ const raw = await fs.readFile(filePath, "utf8");
10
+ const compile = Handlebars.compile(raw);
11
+ return compile(view);
12
+ }
@@ -0,0 +1,41 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ import Handlebars from "handlebars";
5
+
6
+ /**
7
+ * Register partials from a directory.
8
+ * - root partials use prefix "_"
9
+ * - group partials under "@group"
10
+ */
11
+ export async function registerPartials(partialsDir, ext = ".hbs") {
12
+ const entries = await fs.readdir(partialsDir, { withFileTypes: true });
13
+
14
+ 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;
32
+ }
33
+
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
+ }
40
+ }
41
+ }
@@ -0,0 +1,20 @@
1
+ import path from "node:path";
2
+
3
+ import { getNested } from "../utils/object.js";
4
+
5
+ /**
6
+ * Render `${var}` placeholders in each segment of the relPath.
7
+ */
8
+ export function renderPath(relPath, view) {
9
+ const segments = relPath.split(path.sep);
10
+
11
+ const rendered = segments.map((seg) =>
12
+ //NOSONAR -- ignore S5842: Regular expression is safe here
13
+ seg.replace(/\$\{([^}]+)\}/g, (_, expr) => {
14
+ const v = getNested(view, expr.trim());
15
+ return String(v ?? "");
16
+ })
17
+ );
18
+
19
+ return path.join(...rendered);
20
+ }
@@ -0,0 +1,31 @@
1
+ import path from "node:path";
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";
8
+
9
+ /**
10
+ * Main rendering orchestrator.
11
+ */
12
+ export async function renderDirectory(cfg) {
13
+ const { templateDir, partialsDir, outDir, view, extname } = cfg;
14
+
15
+ await registerPartials(partialsDir, extname);
16
+
17
+ const files = await walkTemplateTree(templateDir, extname);
18
+
19
+ for (const file of files) {
20
+ const relRendered = renderPath(file.relPath, view);
21
+ const target = path.join(
22
+ outDir,
23
+ relRendered.replace(new RegExp(`${extname}$`), "")
24
+ );
25
+
26
+ const content = await renderContent(file.absPath, view);
27
+
28
+ await ensureDir(path.dirname(target));
29
+ await writeFileSafe(target, content);
30
+ }
31
+ }
@@ -0,0 +1,32 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ /**
5
+ * BFS async folder walker.
6
+ */
7
+ export async function walkTemplateTree(rootDir, ext = ".hbs", ignore = []) {
8
+ const results = [];
9
+ const queue = [""];
10
+
11
+ while (queue.length) {
12
+ const rel = queue.shift();
13
+ const abs = path.join(rootDir, rel);
14
+ const stat = await fs.stat(abs);
15
+
16
+ if (stat.isDirectory()) {
17
+ const items = await fs.readdir(abs);
18
+ for (const name of items) {
19
+ if (ignore.some((i) => matchIgnore(name, i))) {continue;}
20
+ queue.push(rel ? path.join(rel, name) : name);
21
+ }
22
+ } else if (path.extname(abs) === ext) {
23
+ results.push({ absPath: abs, relPath: rel });
24
+ }
25
+ }
26
+
27
+ return results;
28
+ }
29
+
30
+ function matchIgnore(name, rule) {
31
+ return rule instanceof RegExp ? rule.test(name) : rule === name;
32
+ }
package/src/index.js ADDED
@@ -0,0 +1,2 @@
1
+ export * from "./config/resolver.js";
2
+ export * from "./engine/renderDirectory.js";
@@ -0,0 +1,43 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ /** Safe mkdir -p
5
+ * @param {string} dir Directory path to create
6
+ */
7
+ export async function ensureDir(dir) {
8
+ await fs.mkdir(dir, { recursive: true });
9
+ }
10
+
11
+ /** Safe file write
12
+ * @param {string} file File path to write
13
+ * @param {string} content Content to write
14
+ */
15
+ export async function writeFileSafe(file, content) {
16
+ await fs.writeFile(file, content, "utf8");
17
+ }
18
+
19
+ /** Resolve path relative to cwd
20
+ * @param {string} p Path to resolve
21
+ * @param {string} cwd Current working directory
22
+ * @returns {string} Resolved path
23
+ */
24
+ export function resolvePath(p, cwd = process.cwd()) {
25
+ return path.isAbsolute(p) ? p : path.join(cwd, p);
26
+ }
27
+
28
+ /**
29
+ * Safely resolve a path.
30
+ * If the first segment is absolute, resolves from that segment.
31
+ * Otherwise resolves relative to process.cwd().
32
+ * Does not perform sanitization; caller must ensure inputs are trusted.
33
+ *
34
+ * @param {...string} segments Path segments to join.
35
+ * @returns {string} Absolute path.
36
+ */
37
+ export function safeResolvePath(...segments) {
38
+ const isAbsolute = segments ? segments[0] && path.isAbsolute(segments[0]) : false;
39
+ if (isAbsolute) {
40
+ return path.resolve(...segments);
41
+ }
42
+ return path.resolve(process.cwd(), ...segments);
43
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Retrieve nested value: getNested(obj, "a.b.c")
3
+ */
4
+ export function getNested(obj, key) {
5
+ console.log("getNested", obj, key);
6
+
7
+ return key
8
+ .split(".")
9
+ .reduce((acc, k) => (acc?.[k] ?? undefined), obj);
10
+ }