UNPKG

@enspirit/emb

Version:

A replacement for our Makefile-for-monorepos

103 lines (102 loc) 3.94 kB
// Matches ${source:key} or ${key} patterns // - Source name: word characters only (e.g., "vault", "env") // - Key: word characters, slashes, hashes, dots, with hyphens allowed between segments // (e.g., "secret/path#field", "MY_VAR", "secret/my-app#key") // - Optional fallback: :-value // The key pattern uses (?:-[\w/#.]+)* to allow hyphens only between valid segments, // preventing the key from consuming the :- fallback delimiter. const TPL_REGEX = /(?<!\\)\${(?:(\w+):)?([\w/#.]+(?:-[\w/#.]+)*)(?::-(.*?))?}/g; /** * Check if a source is async (a function). */ function isAsyncSource(source) { return typeof source === 'function'; } export class TemplateExpander { expansions = []; get expansionCount() { return this.expansions.length; } async expand(str, options = {}) { const input = (str || '').toString(); // Collect all matches with their positions const matches = [...input.matchAll(TPL_REGEX)]; if (matches.length === 0) { return input.replaceAll('\\${', '${'); } // Resolve all values (async for function sources, sync for objects) const resolutions = await Promise.all(matches.map(async (match) => { const [fullMatch, sourceName, key, fallback] = match; const src = sourceName ?? options.default ?? ''; const source = options.sources?.[src]; // No source found if (!source) { if (fallback !== undefined) { return { match: fullMatch, value: this.track(src, key, fallback), }; } throw new Error(`Invalid expand provider '${sourceName}' ('${fullMatch}')`); } // Resolve value based on source type let val; if (isAsyncSource(source)) { try { val = await source(key); } catch (error) { if (fallback !== undefined) { return { match: fullMatch, value: this.track(src, key, fallback), }; } throw error; } } else { val = source[key]; } // Handle missing values if (!val && fallback === undefined) { throw new Error(`Could not expand '${fullMatch}' and no default value provided`); } if (val !== undefined && val !== null) { return { match: fullMatch, value: this.track(src, key, val), }; } return { match: fullMatch, value: this.track(src, key, fallback ?? ''), }; })); // Build result string by replacing matches with resolved values let result = input; for (const { match, value } of resolutions) { result = result.replace(match, value); } return result.replaceAll('\\${', '${'); } async expandRecord(record, options) { if (typeof record === 'string') { const out = await this.expand(record, options); return out; } if (Array.isArray(record)) { const out = await Promise.all(record.map((v) => this.expandRecord(v, options))); return out; } const entries = await Promise.all(Object.entries(record).map(async ([k, v]) => { const expandedValue = await this.expandRecord(v, options); return [k, expandedValue]; })); return Object.fromEntries(entries); } track(source, variable, value) { this.expansions.push({ source, value, variable }); return String(value); } }