@nci-gis/js-tmpl 0.0.1-beta.2 → 0.1.0
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 +78 -126
- package/package.json +36 -3
- package/src/cli/args.js +37 -16
- package/src/cli/main.js +24 -11
- package/src/cli/usage.js +17 -0
- package/src/config/defaults.js +8 -6
- package/src/config/loader.js +32 -18
- package/src/config/resolver.js +59 -53
- package/src/config/valuePartials.js +114 -0
- package/src/config/view.js +123 -3
- package/src/engine/contentRenderer.js +24 -7
- package/src/engine/partials.js +81 -33
- package/src/engine/pathFormula.js +74 -0
- package/src/engine/pathRenderer.js +62 -11
- package/src/engine/pathSegment.js +49 -0
- package/src/engine/renderDirectory.js +17 -11
- package/src/engine/treeWalker.js +43 -11
- package/src/index.js +2 -2
- package/src/types.js +30 -0
- package/src/utils/fs.js +5 -4
- package/src/utils/namespacing.js +92 -0
- package/src/utils/object.js +9 -4
|
@@ -1,20 +1,71 @@
|
|
|
1
|
-
import path from
|
|
1
|
+
import path from 'node:path';
|
|
2
2
|
|
|
3
|
-
import { getNested } from
|
|
3
|
+
import { getNested } from '../utils/object.js';
|
|
4
|
+
import { classifySegment } from './pathSegment.js';
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
|
-
*
|
|
7
|
+
* Replace every `${var}` placeholder in a segment with the nested view value.
|
|
8
|
+
* Missing values render as empty strings (existing behavior, unchanged).
|
|
9
|
+
*
|
|
10
|
+
* @param {string} seg
|
|
11
|
+
* @param {Record<string, unknown>} view
|
|
12
|
+
* @returns {string}
|
|
13
|
+
*/
|
|
14
|
+
function expandInterpolations(seg, view) {
|
|
15
|
+
return seg.replaceAll(/\$\{([^}]+)\}/g, (_, expr) => {
|
|
16
|
+
const v = getNested(view, expr.trim());
|
|
17
|
+
return String(v ?? ''); // NOSONAR -- String conversion is intentional here
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Render a single segment. Formulas are rejected in filename position (G-5);
|
|
23
|
+
* formulas in directory position are assumed pre-approved by the walker and
|
|
24
|
+
* collapse to an empty string (G-2). Malformed segments throw.
|
|
25
|
+
*
|
|
26
|
+
* @param {string} seg
|
|
27
|
+
* @param {boolean} isFilename
|
|
28
|
+
* @param {Record<string, unknown>} view
|
|
29
|
+
* @param {string} relPath
|
|
30
|
+
* @returns {string}
|
|
31
|
+
*/
|
|
32
|
+
function renderSegment(seg, isFilename, view, relPath) {
|
|
33
|
+
const c = classifySegment(seg);
|
|
34
|
+
|
|
35
|
+
if (c.kind === 'literal') {
|
|
36
|
+
return seg;
|
|
37
|
+
}
|
|
38
|
+
if (c.kind === 'interpolation') {
|
|
39
|
+
return expandInterpolations(seg, view);
|
|
40
|
+
}
|
|
41
|
+
if (c.kind === 'malformed') {
|
|
42
|
+
throw new Error(`${c.reason} (in '${relPath}')`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// if-formula or ifn-formula
|
|
46
|
+
if (isFilename) {
|
|
47
|
+
throw new Error(
|
|
48
|
+
`Path formula '${seg}' is not allowed in a filename (directories only) — in '${relPath}'`,
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
return '';
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Render all segments of `relPath`. `${var}` is expanded; `$if{var}` /
|
|
56
|
+
* `$ifn{var}` directory segments collapse to empty (the walker already
|
|
57
|
+
* decided inclusion). Filename-position formulas and malformed segments
|
|
58
|
+
* throw.
|
|
59
|
+
*
|
|
60
|
+
* @param {string} relPath
|
|
61
|
+
* @param {Record<string, unknown>} view
|
|
62
|
+
* @returns {string}
|
|
7
63
|
*/
|
|
8
64
|
export function renderPath(relPath, view) {
|
|
9
65
|
const segments = relPath.split(path.sep);
|
|
10
|
-
|
|
11
|
-
const rendered = segments.map((seg) =>
|
|
12
|
-
|
|
13
|
-
seg.replace(/\$\{([^}]+)\}/g, (_, expr) => {
|
|
14
|
-
const v = getNested(view, expr.trim());
|
|
15
|
-
return String(v ?? "");
|
|
16
|
-
})
|
|
66
|
+
const lastIdx = segments.length - 1;
|
|
67
|
+
const rendered = segments.map((seg, idx) =>
|
|
68
|
+
renderSegment(seg, idx === lastIdx, view, relPath),
|
|
17
69
|
);
|
|
18
|
-
|
|
19
70
|
return path.join(...rendered);
|
|
20
71
|
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @typedef {'literal' | 'interpolation' | 'if-formula' | 'ifn-formula' | 'malformed'} SegmentKind
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @typedef {object} SegmentClassification
|
|
7
|
+
* @property {SegmentKind} kind
|
|
8
|
+
* @property {string} [var] - Trimmed variable expression (if-formula / ifn-formula only).
|
|
9
|
+
* @property {string} [reason] - Human-readable reason (malformed only).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const FORMULA_WHOLE = /^\$(if|ifn)\{([^}]+)\}$/;
|
|
13
|
+
const FORMULA_SUBSTR = /\$ifn?\{/;
|
|
14
|
+
const INTERPOLATION = /\$\{[^}]+\}/;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Classify a single path segment.
|
|
18
|
+
*
|
|
19
|
+
* Pure and total — never throws. Callers dispatch on the returned `kind`.
|
|
20
|
+
*
|
|
21
|
+
* Kinds:
|
|
22
|
+
* - `if-formula` — whole segment matches `$if{var}`; `.var` holds the trimmed expression.
|
|
23
|
+
* - `ifn-formula` — whole segment matches `$ifn{var}`; `.var` holds the trimmed expression.
|
|
24
|
+
* - `malformed` — contains `$if{` or `$ifn{` but is not a whole-segment formula; `.reason` describes the violation.
|
|
25
|
+
* - `interpolation` — contains one or more `${var}` placeholders.
|
|
26
|
+
* - `literal` — none of the above.
|
|
27
|
+
*
|
|
28
|
+
* @param {string} segment
|
|
29
|
+
* @returns {SegmentClassification}
|
|
30
|
+
*/
|
|
31
|
+
export function classifySegment(segment) {
|
|
32
|
+
const m = FORMULA_WHOLE.exec(segment);
|
|
33
|
+
if (m) {
|
|
34
|
+
return {
|
|
35
|
+
kind: m[1] === 'if' ? 'if-formula' : 'ifn-formula',
|
|
36
|
+
var: m[2].trim(),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
if (FORMULA_SUBSTR.test(segment)) {
|
|
40
|
+
return {
|
|
41
|
+
kind: 'malformed',
|
|
42
|
+
reason: `formulas must be whole segments in directory positions, one per segment: got '${segment}'`,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
if (INTERPOLATION.test(segment)) {
|
|
46
|
+
return { kind: 'interpolation' };
|
|
47
|
+
}
|
|
48
|
+
return { kind: 'literal' };
|
|
49
|
+
}
|
|
@@ -1,29 +1,35 @@
|
|
|
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
|
-
const files = await walkTemplateTree(templateDir, extname);
|
|
23
|
+
const files = await walkTemplateTree(templateDir, { ext: extname, view });
|
|
18
24
|
|
|
19
25
|
for (const file of files) {
|
|
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, file.relPath);
|
|
27
33
|
|
|
28
34
|
await ensureDir(path.dirname(target));
|
|
29
35
|
await writeFileSafe(target, content);
|
package/src/engine/treeWalker.js
CHANGED
|
@@ -1,22 +1,58 @@
|
|
|
1
|
-
import fs from
|
|
2
|
-
import path from
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
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
|
+
}
|
|
3
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
|
+
*
|
|
30
|
+
* @param {string} rootDir
|
|
31
|
+
* @param {(string | { ext?: string, view?: Record<string, unknown> })} [optsOrExt]
|
|
32
|
+
* Options object, or a bare `ext` string for back-compat.
|
|
33
|
+
* @returns {Promise<import('../types.js').TemplateFile[]>}
|
|
6
34
|
*/
|
|
7
|
-
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[]} */
|
|
8
42
|
const results = [];
|
|
9
|
-
const queue = [
|
|
43
|
+
const queue = [''];
|
|
10
44
|
|
|
11
45
|
while (queue.length) {
|
|
12
|
-
const rel = queue.shift();
|
|
46
|
+
const rel = /** @type {string} */ (queue.shift());
|
|
13
47
|
const abs = path.join(rootDir, rel);
|
|
14
48
|
const stat = await fs.stat(abs);
|
|
15
49
|
|
|
16
50
|
if (stat.isDirectory()) {
|
|
17
|
-
|
|
51
|
+
if (shouldSkipSubtree(rel, view)) {
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
const items = (await fs.readdir(abs)).sort();
|
|
18
55
|
for (const name of items) {
|
|
19
|
-
if (ignore.some((i) => matchIgnore(name, i))) {continue;}
|
|
20
56
|
queue.push(rel ? path.join(rel, name) : name);
|
|
21
57
|
}
|
|
22
58
|
} else if (path.extname(abs) === ext) {
|
|
@@ -26,7 +62,3 @@ export async function walkTemplateTree(rootDir, ext = ".hbs", ignore = []) {
|
|
|
26
62
|
|
|
27
63
|
return results;
|
|
28
64
|
}
|
|
29
|
-
|
|
30
|
-
function matchIgnore(name, rule) {
|
|
31
|
-
return rule instanceof RegExp ? rule.test(name) : rule === name;
|
|
32
|
-
}
|
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
|
}
|
|
@@ -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/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
|
}
|