@nci-gis/js-tmpl 0.0.1 → 0.1.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 +82 -23
- package/bin/js-tmpl.js +8 -0
- package/package.json +17 -9
- package/src/cli/args.js +130 -53
- package/src/cli/main.js +39 -18
- package/src/cli/usage.js +9 -2
- package/src/config/resolver.js +45 -48
- package/src/config/valuePartials.js +114 -0
- package/src/config/view.js +85 -12
- package/src/engine/contentRenderer.js +19 -6
- package/src/engine/helpers.js +93 -0
- package/src/engine/partials.js +26 -54
- package/src/engine/pathFormula.js +74 -0
- package/src/engine/pathRenderer.js +57 -9
- package/src/engine/pathSegment.js +49 -0
- package/src/engine/renderDirectory.js +61 -10
- package/src/engine/treeWalker.js +36 -15
- package/src/index.js +1 -0
- package/src/types.js +1 -0
- package/src/utils/namespacing.js +92 -0
- package/bin/js-tmpl +0 -3
|
@@ -8,6 +8,63 @@ import { registerPartials } from './partials.js';
|
|
|
8
8
|
import { renderPath } from './pathRenderer.js';
|
|
9
9
|
import { walkTemplateTree } from './treeWalker.js';
|
|
10
10
|
|
|
11
|
+
/**
|
|
12
|
+
* Throw if `target` is not strictly inside `outDir` — a `${var}` value such
|
|
13
|
+
* as `../x` must never write outside the output directory.
|
|
14
|
+
*
|
|
15
|
+
* @param {string} target
|
|
16
|
+
* @param {string} outDir
|
|
17
|
+
* @param {string} relPath - Template path, for the error message
|
|
18
|
+
* @param {string} rendered - Rendered path, for the error message
|
|
19
|
+
*/
|
|
20
|
+
function assertInsideOutDir(target, outDir, relPath, rendered) {
|
|
21
|
+
const rel = path.relative(path.resolve(outDir), path.resolve(target));
|
|
22
|
+
const escapes =
|
|
23
|
+
rel === '' || rel.split(path.sep)[0] === '..' || path.isAbsolute(rel);
|
|
24
|
+
if (escapes) {
|
|
25
|
+
const vars = [...relPath.matchAll(/\$\{([^}]+)\}/g)].map((m) => m[1]);
|
|
26
|
+
throw new Error(
|
|
27
|
+
`Template '${relPath}' renders to '${rendered}', which is outside outDir '${outDir}'.\n` +
|
|
28
|
+
(vars.length ? `Check the values of: ${vars.join(', ')}. ` : '') +
|
|
29
|
+
"Path values must not contain '..' segments.",
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Map every template to its output path before anything is rendered or
|
|
36
|
+
* written, so an escaping path or two templates sharing a target fail
|
|
37
|
+
* before the first file is touched.
|
|
38
|
+
*
|
|
39
|
+
* @param {Array<{ relPath: string, absPath: string }>} files
|
|
40
|
+
* @param {import('../types.js').TemplateConfig} cfg
|
|
41
|
+
* @returns {Array<{ file: { relPath: string, absPath: string }, target: string }>}
|
|
42
|
+
*/
|
|
43
|
+
function planTargets(files, cfg) {
|
|
44
|
+
const { outDir, view, extname } = cfg;
|
|
45
|
+
/** @type {Map<string, string>} */
|
|
46
|
+
const owners = new Map();
|
|
47
|
+
|
|
48
|
+
return files.map((file) => {
|
|
49
|
+
const rendered = renderPath(file.relPath, view).replace(
|
|
50
|
+
new RegExp(`${extname}$`),
|
|
51
|
+
'',
|
|
52
|
+
);
|
|
53
|
+
const target = path.join(outDir, rendered);
|
|
54
|
+
assertInsideOutDir(target, outDir, file.relPath, rendered);
|
|
55
|
+
|
|
56
|
+
const owner = owners.get(target);
|
|
57
|
+
if (owner) {
|
|
58
|
+
throw new Error(
|
|
59
|
+
`Templates '${owner}' and '${file.relPath}' both render to '${rendered}'.\n` +
|
|
60
|
+
'Each output file must come from exactly one template; check the path values.',
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
owners.set(target, file.relPath);
|
|
64
|
+
return { file, target };
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
11
68
|
/**
|
|
12
69
|
* Main rendering orchestrator.
|
|
13
70
|
* @param {import('../types.js').TemplateConfig} cfg
|
|
@@ -15,21 +72,15 @@ import { walkTemplateTree } from './treeWalker.js';
|
|
|
15
72
|
* @returns {Promise<void>}
|
|
16
73
|
*/
|
|
17
74
|
export async function renderDirectory(cfg, hbs) {
|
|
18
|
-
const { templateDir, partialsDir,
|
|
75
|
+
const { templateDir, partialsDir, view, extname } = cfg;
|
|
19
76
|
|
|
20
77
|
hbs = hbs || Handlebars.create();
|
|
21
78
|
await registerPartials(partialsDir, extname, hbs);
|
|
22
79
|
|
|
23
|
-
const files = await walkTemplateTree(templateDir, extname);
|
|
24
|
-
|
|
25
|
-
for (const file of files) {
|
|
26
|
-
const relRendered = renderPath(file.relPath, view);
|
|
27
|
-
const target = path.join(
|
|
28
|
-
outDir,
|
|
29
|
-
relRendered.replace(new RegExp(`${extname}$`), ''),
|
|
30
|
-
);
|
|
80
|
+
const files = await walkTemplateTree(templateDir, { ext: extname, view });
|
|
31
81
|
|
|
32
|
-
|
|
82
|
+
for (const { file, target } of planTargets(files, cfg)) {
|
|
83
|
+
const content = await renderContent(file.absPath, view, hbs, file.relPath);
|
|
33
84
|
|
|
34
85
|
await ensureDir(path.dirname(target));
|
|
35
86
|
await writeFileSafe(target, content);
|
package/src/engine/treeWalker.js
CHANGED
|
@@ -1,14 +1,44 @@
|
|
|
1
1
|
import fs from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
|
|
4
|
+
import { evalFormula } from './pathFormula.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* True when a directory's basename is a path formula that evaluates to skip
|
|
8
|
+
* against `view`. Subtree pruning happens here — the walker short-circuits
|
|
9
|
+
* before any `readdir`.
|
|
10
|
+
*
|
|
11
|
+
* @param {string} rel - Relative path from the walk root (empty = root itself)
|
|
12
|
+
* @param {Record<string, unknown> | undefined} view
|
|
13
|
+
* @returns {boolean}
|
|
14
|
+
*/
|
|
15
|
+
function shouldSkipSubtree(rel, view) {
|
|
16
|
+
if (!rel || view === undefined) {
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
return evalFormula(path.basename(rel), view, rel) === 'skip';
|
|
20
|
+
}
|
|
21
|
+
|
|
4
22
|
/**
|
|
5
23
|
* BFS async folder walker.
|
|
24
|
+
*
|
|
25
|
+
* When `view` is provided, directory segments that match path-formula syntax
|
|
26
|
+
* (`$if{var}` / `$ifn{var}`) are evaluated against the view; failing formulas
|
|
27
|
+
* prune the subtree before any filesystem descent (early-exit — no
|
|
28
|
+
* `stat`/`readdir` on skipped paths).
|
|
29
|
+
*
|
|
6
30
|
* @param {string} rootDir
|
|
7
|
-
* @param {string} [
|
|
8
|
-
*
|
|
31
|
+
* @param {(string | { ext?: string, view?: Record<string, unknown> })} [optsOrExt]
|
|
32
|
+
* Options object, or a bare `ext` string for back-compat.
|
|
9
33
|
* @returns {Promise<import('../types.js').TemplateFile[]>}
|
|
10
34
|
*/
|
|
11
|
-
export async function walkTemplateTree(rootDir,
|
|
35
|
+
export async function walkTemplateTree(rootDir, optsOrExt) {
|
|
36
|
+
const opts =
|
|
37
|
+
typeof optsOrExt === 'string' ? { ext: optsOrExt } : optsOrExt || {};
|
|
38
|
+
const ext = opts.ext ?? '.hbs';
|
|
39
|
+
const view = opts.view;
|
|
40
|
+
|
|
41
|
+
/** @type {import('../types.js').TemplateFile[]} */
|
|
12
42
|
const results = [];
|
|
13
43
|
const queue = [''];
|
|
14
44
|
|
|
@@ -18,11 +48,11 @@ export async function walkTemplateTree(rootDir, ext = '.hbs', ignore = []) {
|
|
|
18
48
|
const stat = await fs.stat(abs);
|
|
19
49
|
|
|
20
50
|
if (stat.isDirectory()) {
|
|
51
|
+
if (shouldSkipSubtree(rel, view)) {
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
21
54
|
const items = (await fs.readdir(abs)).sort();
|
|
22
55
|
for (const name of items) {
|
|
23
|
-
if (ignore.some((i) => matchIgnore(name, i))) {
|
|
24
|
-
continue;
|
|
25
|
-
}
|
|
26
56
|
queue.push(rel ? path.join(rel, name) : name);
|
|
27
57
|
}
|
|
28
58
|
} else if (path.extname(abs) === ext) {
|
|
@@ -32,12 +62,3 @@ export async function walkTemplateTree(rootDir, ext = '.hbs', ignore = []) {
|
|
|
32
62
|
|
|
33
63
|
return results;
|
|
34
64
|
}
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* @param {string} name
|
|
38
|
-
* @param {string | RegExp} rule
|
|
39
|
-
* @returns {boolean}
|
|
40
|
-
*/
|
|
41
|
-
function matchIgnore(name, rule) {
|
|
42
|
-
return rule instanceof RegExp ? rule.test(name) : rule === name;
|
|
43
|
-
}
|
package/src/index.js
CHANGED
package/src/types.js
CHANGED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Valid namespace segment: letters, digits, underscore (matches Handlebars
|
|
5
|
+
* bare-identifier convention and the partials system's historical rule).
|
|
6
|
+
*/
|
|
7
|
+
export const SEGMENT_RE = /^\w+$/;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Match a "@name" flatten marker at any position in the relative path.
|
|
11
|
+
* Root-independent: scan-root choice does not change the outcome, because
|
|
12
|
+
* the marker is detected wherever it appears in the chain.
|
|
13
|
+
*/
|
|
14
|
+
const FLATTEN_SEGMENT_RE = /^@\w+$/;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Throw a clear error when a namespace segment contains invalid characters.
|
|
18
|
+
*
|
|
19
|
+
* @param {string[]} segments - Chain as produced by `deriveNamespace`.
|
|
20
|
+
* @param {string} filePath - Absolute or repo-relative path, for the error message.
|
|
21
|
+
* @param {string} [label='namespace'] - Noun used in the error message (e.g. "partial name").
|
|
22
|
+
*/
|
|
23
|
+
export function assertValidSegments(segments, filePath, label = 'namespace') {
|
|
24
|
+
if (!SEGMENT_RE.test(segments.join(''))) {
|
|
25
|
+
throw new Error(
|
|
26
|
+
`Invalid ${label} segment '${segments.join('>')}' in ${filePath} — only alphanumeric and underscore allowed`,
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Throw a clear error when two files resolve to the same namespace key.
|
|
33
|
+
*
|
|
34
|
+
* @param {Map<string, string>} seen - Key → absolute path of the first file that claimed it.
|
|
35
|
+
* @param {string} key - The colliding namespace (dot-joined chain or single name).
|
|
36
|
+
* @param {string} filePath - Absolute path of the second file.
|
|
37
|
+
* @param {string} rootDir - The scan root, used to produce relative paths in the error.
|
|
38
|
+
* @param {string} [label='namespace'] - Noun used in the error message (e.g. "partial name").
|
|
39
|
+
*/
|
|
40
|
+
export function assertNoDuplicate(
|
|
41
|
+
seen,
|
|
42
|
+
key,
|
|
43
|
+
filePath,
|
|
44
|
+
rootDir,
|
|
45
|
+
label = 'namespace',
|
|
46
|
+
) {
|
|
47
|
+
const existing = seen.get(key);
|
|
48
|
+
if (existing) {
|
|
49
|
+
const rel1 = path.relative(rootDir, existing);
|
|
50
|
+
const rel2 = path.relative(rootDir, filePath);
|
|
51
|
+
throw new Error(
|
|
52
|
+
`Duplicate ${label} '${key}' — registered by both:\n` +
|
|
53
|
+
` - ${rel1}\n` +
|
|
54
|
+
` - ${rel2}\n` +
|
|
55
|
+
`Use namespaced directories to avoid collisions.`,
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
seen.set(key, filePath);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Derive a namespace chain from a relative file path.
|
|
63
|
+
*
|
|
64
|
+
* Rules:
|
|
65
|
+
* - Extension is stripped from the final segment.
|
|
66
|
+
* - If any segment in the chain matches `^@\w+$` (a flatten marker), the chain
|
|
67
|
+
* collapses to `[basename]` — the file contributes at top level of view /
|
|
68
|
+
* partial registry, regardless of where the `@name` appears. This is the
|
|
69
|
+
* **root-independent** flatten rule: scanning `values/` vs `values/env/`
|
|
70
|
+
* yields the same namespace for `values/env/@overrides/app.yaml`.
|
|
71
|
+
* - Otherwise, the chain is the directory path split by `path.sep`, with the
|
|
72
|
+
* final file's extension trimmed.
|
|
73
|
+
*
|
|
74
|
+
* The function does not validate segments — that's `assertValidSegments`'
|
|
75
|
+
* job, called by the scan helpers that consume this chain.
|
|
76
|
+
*
|
|
77
|
+
* @param {object} args
|
|
78
|
+
* @param {string} args.relPath - Relative path from the scan root.
|
|
79
|
+
* @param {string} args.ext - Extension to strip from the basename (include the dot, e.g. `.yaml`).
|
|
80
|
+
* @returns {string[]} The derived namespace chain.
|
|
81
|
+
*/
|
|
82
|
+
export function deriveNamespace({ relPath, ext }) {
|
|
83
|
+
const trimmed = relPath.endsWith(ext)
|
|
84
|
+
? relPath.slice(0, -ext.length)
|
|
85
|
+
: relPath;
|
|
86
|
+
const segments = trimmed.split(path.sep);
|
|
87
|
+
|
|
88
|
+
if (segments.some((s) => FLATTEN_SEGMENT_RE.test(s))) {
|
|
89
|
+
return [/** @type {string} */ (segments.at(-1))];
|
|
90
|
+
}
|
|
91
|
+
return segments;
|
|
92
|
+
}
|
package/bin/js-tmpl
DELETED