@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 +21 -0
- package/README.md +348 -0
- package/bin/js-tmpl +3 -0
- package/package.json +62 -0
- package/src/cli/args.js +53 -0
- package/src/cli/main.js +28 -0
- package/src/config/defaults.js +8 -0
- package/src/config/loader.js +58 -0
- package/src/config/resolver.js +70 -0
- package/src/config/view.js +6 -0
- package/src/engine/contentRenderer.js +12 -0
- package/src/engine/partials.js +41 -0
- package/src/engine/pathRenderer.js +20 -0
- package/src/engine/renderDirectory.js +31 -0
- package/src/engine/treeWalker.js +32 -0
- package/src/index.js +2 -0
- package/src/utils/fs.js +43 -0
- package/src/utils/object.js +10 -0
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
|
+
[](https://www.npmjs.com/package/@nci-gis/js-tmpl)
|
|
6
|
+
[](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
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
|
+
}
|
package/src/cli/args.js
ADDED
|
@@ -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
|
+
}
|
package/src/cli/main.js
ADDED
|
@@ -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,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,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
package/src/utils/fs.js
ADDED
|
@@ -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
|
+
}
|