@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 +136 -128
- package/package.json +42 -14
- package/src/cli/args.js +33 -16
- package/src/cli/main.js +24 -11
- package/src/cli/usage.js +16 -0
- package/src/config/defaults.js +8 -6
- package/src/config/loader.js +42 -19
- package/src/config/resolver.js +53 -36
- package/src/config/view.js +48 -1
- package/src/engine/contentRenderer.js +9 -5
- package/src/engine/partials.js +107 -31
- package/src/engine/pathRenderer.js +7 -4
- package/src/engine/renderDirectory.js +16 -10
- package/src/engine/treeWalker.js +18 -7
- package/src/index.js +2 -2
- package/src/types.js +30 -0
- package/src/utils/fs.js +5 -4
- package/src/utils/object.js +9 -4
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
|
[](https://www.npmjs.com/package/@nci-gis/js-tmpl)
|
|
6
8
|
[](https://opensource.org/licenses/MIT)
|
|
7
9
|
|
|
8
10
|
## What is js-tmpl?
|
|
9
11
|
|
|
10
|
-
js-tmpl is a **pure transformation layer** that
|
|
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
|
|
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
|
-
-
|
|
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 [
|
|
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
|
-
|
|
80
|
-
|
|
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
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
|
|
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
|
-
|
|
221
|
+
├── header.hbs → {{> header}}
|
|
222
|
+
├── components/
|
|
223
|
+
│ ├── button.hbs → {{> components.button}}
|
|
224
|
+
│ └── forms/
|
|
225
|
+
│ └── login.hbs → {{> components.forms.login}}
|
|
166
226
|
```
|
|
167
227
|
|
|
168
|
-
|
|
228
|
+
**`@` directories** flatten their contents (filename only, no namespace):
|
|
169
229
|
|
|
170
230
|
```text
|
|
171
|
-
|
|
172
|
-
└──
|
|
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
|
|
185
|
-
| ------------------------ |
|
|
186
|
-
| `-c, --values FILE` | Values file (YAML/JSON)
|
|
187
|
-
| `-t, --template-dir DIR` | Template directory
|
|
188
|
-
| `-o, --out DIR` | Output directory
|
|
189
|
-
| `-p, --partials-dir DIR` | Partials directory
|
|
190
|
-
| `-x, --ext EXT` | Template extension
|
|
191
|
-
| `--config-file FILE` | Explicit config file
|
|
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
|
|
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
|
|
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
|
-
##
|
|
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
|
-
- [
|
|
346
|
-
- [
|
|
347
|
-
- [
|
|
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
|
|
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
|
-
"
|
|
51
|
-
"
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
"
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
"
|
|
60
|
-
|
|
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 {
|
|
5
|
+
* @returns {import('../types.js').CliArgs}
|
|
6
6
|
*/
|
|
7
7
|
export function parseArgs(args) {
|
|
8
|
-
|
|
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
|
|
16
|
-
|
|
16
|
+
case '-h':
|
|
17
|
+
case '--help':
|
|
18
|
+
opts.command = 'help';
|
|
17
19
|
break;
|
|
18
20
|
|
|
19
|
-
case
|
|
20
|
-
|
|
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
|
|
25
|
-
case
|
|
30
|
+
case '-c':
|
|
31
|
+
case '--values':
|
|
26
32
|
opts.valuesFile = args[++i];
|
|
27
33
|
break;
|
|
28
34
|
|
|
29
|
-
case
|
|
30
|
-
case
|
|
35
|
+
case '-o':
|
|
36
|
+
case '--out':
|
|
31
37
|
opts.outDir = args[++i];
|
|
32
38
|
break;
|
|
33
39
|
|
|
34
|
-
case
|
|
35
|
-
case
|
|
40
|
+
case '-p':
|
|
41
|
+
case '--partials-dir':
|
|
36
42
|
opts.partialsDir = args[++i];
|
|
37
43
|
break;
|
|
38
44
|
|
|
39
|
-
case
|
|
45
|
+
case '--config-file':
|
|
40
46
|
opts.configFile = args[++i];
|
|
41
47
|
break;
|
|
42
48
|
|
|
43
|
-
case
|
|
44
|
-
case
|
|
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
|
|
4
|
-
import { renderDirectory } from
|
|
5
|
-
import { parseArgs } from
|
|
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
|
|
11
|
-
console.
|
|
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(
|
|
26
|
+
console.log('✔ js-tmpl completed.');
|
|
20
27
|
}
|
|
21
28
|
|
|
22
29
|
// Execute main function if this file is run directly
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
}
|
package/src/cli/usage.js
ADDED
|
@@ -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`;
|
package/src/config/defaults.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
export const DEFAULTS = {
|
|
2
|
-
templateDir:
|
|
3
|
-
partialsDir:
|
|
4
|
-
valuesDir:
|
|
5
|
-
valuesFile:
|
|
6
|
-
outDir:
|
|
7
|
-
extname:
|
|
2
|
+
templateDir: 'templates',
|
|
3
|
+
partialsDir: '',
|
|
4
|
+
valuesDir: '',
|
|
5
|
+
valuesFile: '',
|
|
6
|
+
outDir: 'dist',
|
|
7
|
+
extname: '.hbs',
|
|
8
|
+
envKeys: [],
|
|
9
|
+
envPrefix: '',
|
|
8
10
|
};
|
package/src/config/loader.js
CHANGED
|
@@ -1,22 +1,30 @@
|
|
|
1
|
-
import fs from
|
|
2
|
-
import path from
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
3
|
|
|
4
|
-
import YAML from
|
|
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 {
|
|
10
|
+
* @returns {Record<string, unknown>} Parsed values.
|
|
11
11
|
*/
|
|
12
12
|
export function loadYamlOrJson(filePath) {
|
|
13
|
-
|
|
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 {
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
path.join(
|
|
40
|
-
path.join(
|
|
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)) {
|
|
65
|
+
if (!fs.existsSync(abs)) {
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
46
68
|
|
|
47
|
-
const raw = fs.readFileSync(abs,
|
|
69
|
+
const raw = fs.readFileSync(abs, 'utf8');
|
|
48
70
|
|
|
49
|
-
if (/\.ya?ml
|
|
50
|
-
return YAML.load(raw) || {};
|
|
71
|
+
if (/\.ya?ml$/i.test(abs)) {
|
|
72
|
+
return /** @type {Record<string, unknown>} */ (YAML.load(raw) || {});
|
|
51
73
|
}
|
|
52
|
-
|
|
53
|
-
|
|
74
|
+
|
|
75
|
+
if (/\.json$/i.test(abs)) {
|
|
76
|
+
return /** @type {Record<string, unknown>} */ (JSON.parse(raw));
|
|
54
77
|
}
|
|
55
78
|
}
|
|
56
79
|
|
package/src/config/resolver.js
CHANGED
|
@@ -1,38 +1,42 @@
|
|
|
1
|
-
import
|
|
2
|
-
import
|
|
3
|
-
import process from "node:process";
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import process from 'node:process';
|
|
4
3
|
|
|
5
|
-
import { DEFAULTS } from
|
|
6
|
-
import { loadProjectConfig,loadYamlOrJson } from
|
|
7
|
-
import { buildView } from
|
|
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
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* @param {string}
|
|
14
|
-
* @returns {string
|
|
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
|
|
17
|
-
|
|
15
|
+
function resolveValuesFilePath(valuesFile, valuesDir, cwd) {
|
|
16
|
+
// If absolute, use as-is
|
|
17
|
+
if (path.isAbsolute(valuesFile)) {
|
|
18
|
+
return valuesFile;
|
|
19
|
+
}
|
|
18
20
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
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 {
|
|
34
|
-
* @param {string} cwd - Current working directory
|
|
35
|
-
* @returns {
|
|
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
|
-
//
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/config/view.js
CHANGED
|
@@ -1,6 +1,53 @@
|
|
|
1
|
-
|
|
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
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
2
|
|
|
3
|
-
import Handlebars from
|
|
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,
|
|
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
|
}
|
package/src/engine/partials.js
CHANGED
|
@@ -1,41 +1,117 @@
|
|
|
1
|
-
import fs from
|
|
2
|
-
import path from
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
3
|
|
|
4
|
-
|
|
4
|
+
const VALID_SEGMENT = /^\w+$/;
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
7
|
+
* Validate a partial name segment (directory or file basename).
|
|
8
|
+
* @param {string[]} segments
|
|
9
|
+
* @param {string} filePath
|
|
10
10
|
*/
|
|
11
|
-
|
|
12
|
-
|
|
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
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
|
1
|
+
import path from 'node:path';
|
|
2
2
|
|
|
3
|
-
import { getNested } from
|
|
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
|
|
1
|
+
import path from 'node:path';
|
|
2
2
|
|
|
3
|
-
import
|
|
4
|
-
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
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
|
-
|
|
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);
|
package/src/engine/treeWalker.js
CHANGED
|
@@ -1,22 +1,28 @@
|
|
|
1
|
-
import fs from
|
|
2
|
-
import path from
|
|
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 =
|
|
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))) {
|
|
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
|
|
2
|
-
export * from
|
|
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
|
|
2
|
-
import path from
|
|
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,
|
|
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 =
|
|
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
|
}
|
package/src/utils/object.js
CHANGED
|
@@ -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(
|
|
9
|
+
.split('.')
|
|
10
|
+
.reduce(
|
|
11
|
+
(/** @type {unknown} */ acc, /** @type {string} */ k) =>
|
|
12
|
+
/** @type {Record<string, unknown>} */ (acc)?.[k] ?? undefined,
|
|
13
|
+
obj,
|
|
14
|
+
);
|
|
10
15
|
}
|