@enspirit/emb
Version:
A replacement for our Makefile-for-monorepos
103 lines (102 loc) • 3.94 kB
JavaScript
// 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);
}
}