@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.
@@ -1,20 +1,71 @@
1
- import path from "node:path";
1
+ import path from 'node:path';
2
2
 
3
- import { getNested } from "../utils/object.js";
3
+ import { getNested } from '../utils/object.js';
4
+ import { classifySegment } from './pathSegment.js';
4
5
 
5
6
  /**
6
- * Render `${var}` placeholders in each segment of the relPath.
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
- //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
- })
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 "node:path";
1
+ import path from 'node:path';
2
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";
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
- await registerPartials(partialsDir, extname);
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);
@@ -1,22 +1,58 @@
1
- import fs from "node:fs/promises";
2
- import path from "node:path";
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, ext = ".hbs", ignore = []) {
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
- const items = await fs.readdir(abs);
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 "./config/resolver.js";
2
- export * from "./engine/renderDirectory.js";
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 "node:fs/promises";
2
- import path from "node:path";
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, "utf8");
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 = segments ? segments[0] && path.isAbsolute(segments[0]) : false;
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
+ }
@@ -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((acc, k) => (acc?.[k] ?? undefined), obj);
9
+ .split('.')
10
+ .reduce(
11
+ (/** @type {unknown} */ acc, /** @type {string} */ k) =>
12
+ /** @type {Record<string, unknown>} */ (acc)?.[k] ?? undefined,
13
+ obj,
14
+ );
10
15
  }