configorama 0.9.5 → 0.9.11

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.
Files changed (65) hide show
  1. package/README.md +156 -5
  2. package/package.json +20 -2
  3. package/src/main.js +268 -105
  4. package/src/parsers/esm.js +0 -14
  5. package/src/parsers/hcl-parse-script.js +40 -0
  6. package/src/parsers/hcl.js +131 -3
  7. package/src/parsers/hcl.slow-test.js +141 -0
  8. package/src/parsers/index.js +3 -1
  9. package/src/parsers/typescript.js +0 -10
  10. package/src/resolvers/valueFromEval.js +69 -11
  11. package/src/resolvers/valueFromFile.js +54 -1
  12. package/src/resolvers/valueFromIf.js +75 -0
  13. package/src/resolvers/valueFromIf.test.js +66 -0
  14. package/src/resolvers/valueFromNumber.js +3 -0
  15. package/src/utils/handleSignalEvents.js +3 -4
  16. package/src/utils/lodash.js +18 -7
  17. package/src/utils/parsing/cloudformationSchema.js +1 -2
  18. package/src/utils/parsing/cloudformationSchema.test.js +14 -0
  19. package/src/utils/parsing/parse.js +11 -1
  20. package/src/utils/parsing/preProcess.js +220 -5
  21. package/src/utils/paths/getFullFilePath.js +6 -2
  22. package/src/utils/paths/getFullFilePath.test.js +18 -0
  23. package/src/utils/regex/index.js +18 -3
  24. package/src/utils/regex/index.test.js +24 -0
  25. package/src/utils/strings/quoteAware.js +141 -0
  26. package/src/utils/strings/replaceAll.js +13 -1
  27. package/src/utils/strings/splitByComma.js +25 -15
  28. package/src/utils/strings/splitByComma.test.js +19 -0
  29. package/src/utils/strings/splitOnPipe.js +30 -0
  30. package/src/utils/strings/splitOnPipe.test.js +68 -0
  31. package/src/utils/validation/isValidValue.test.js +1 -1
  32. package/src/utils/validation/warnIfNotFound.js +1 -1
  33. package/src/utils/variables/findNestedVariables.js +8 -2
  34. package/types/src/main.d.ts +3 -1
  35. package/types/src/main.d.ts.map +1 -1
  36. package/types/src/parsers/esm.d.ts.map +1 -1
  37. package/types/src/parsers/hcl-parse-script.d.ts +3 -0
  38. package/types/src/parsers/hcl-parse-script.d.ts.map +1 -0
  39. package/types/src/parsers/hcl.d.ts +43 -0
  40. package/types/src/parsers/hcl.d.ts.map +1 -1
  41. package/types/src/parsers/hcl.slow-test.d.ts +2 -0
  42. package/types/src/parsers/hcl.slow-test.d.ts.map +1 -0
  43. package/types/src/parsers/typescript.d.ts.map +1 -1
  44. package/types/src/resolvers/valueFromEval.d.ts +1 -0
  45. package/types/src/resolvers/valueFromEval.d.ts.map +1 -1
  46. package/types/src/resolvers/valueFromFile.d.ts +4 -0
  47. package/types/src/resolvers/valueFromFile.d.ts.map +1 -1
  48. package/types/src/resolvers/valueFromIf.d.ts +7 -0
  49. package/types/src/resolvers/valueFromIf.d.ts.map +1 -0
  50. package/types/src/resolvers/valueFromNumber.d.ts.map +1 -1
  51. package/types/src/utils/handleSignalEvents.d.ts.map +1 -1
  52. package/types/src/utils/lodash.d.ts.map +1 -1
  53. package/types/src/utils/parsing/parse.d.ts.map +1 -1
  54. package/types/src/utils/parsing/preProcess.d.ts +5 -1
  55. package/types/src/utils/parsing/preProcess.d.ts.map +1 -1
  56. package/types/src/utils/paths/getFullFilePath.d.ts.map +1 -1
  57. package/types/src/utils/regex/index.d.ts.map +1 -1
  58. package/types/src/utils/strings/quoteAware.d.ts +30 -0
  59. package/types/src/utils/strings/quoteAware.d.ts.map +1 -0
  60. package/types/src/utils/strings/replaceAll.d.ts.map +1 -1
  61. package/types/src/utils/strings/splitByComma.d.ts +1 -1
  62. package/types/src/utils/strings/splitByComma.d.ts.map +1 -1
  63. package/types/src/utils/strings/splitOnPipe.d.ts +8 -0
  64. package/types/src/utils/strings/splitOnPipe.d.ts.map +1 -0
  65. package/types/src/utils/variables/findNestedVariables.d.ts.map +1 -1
package/src/main.js CHANGED
@@ -42,6 +42,7 @@ const { splitCsv } = require('./utils/strings/splitCsv')
42
42
  const { replaceAll } = require('./utils/strings/replaceAll')
43
43
  const { getTextAfterOccurrence, findNestedVariable } = require('./utils/strings/textUtils')
44
44
  const { ensureQuote, isSurroundedByQuotes, startsWithQuotedPipe } = require('./utils/strings/quoteUtils')
45
+ const { splitOnPipe } = require('./utils/strings/splitOnPipe')
45
46
  /* Utils - ui */
46
47
  const chalk = require('./utils/ui/chalk')
47
48
  const deepLog = require('./utils/ui/deep-log')
@@ -63,6 +64,8 @@ const getValueFromOptions = require('./resolvers/valueFromOptions')
63
64
  const getValueFromParam = require('./resolvers/valueFromParam')
64
65
  const getValueFromCron = require('./resolvers/valueFromCron')
65
66
  const getValueFromEval = require('./resolvers/valueFromEval')
67
+ const { encodeValue: encodeValueForEval } = require('./resolvers/valueFromEval')
68
+ const getValueFromIf = require('./resolvers/valueFromIf')
66
69
  const createGitResolver = require('./resolvers/valueFromGit')
67
70
  const { getValueFromFile: getValueFromFileResolver } = require('./resolvers/valueFromFile')
68
71
  /* Parsers */
@@ -151,6 +154,8 @@ class Configorama {
151
154
  this.settings.allowUnresolvedVariables = unresolvedSetting
152
155
 
153
156
  this.filterCache = {}
157
+ // Cache for originalValue lookups (perf: avoid repeated dotProp.get)
158
+ this._originalValueCache = new Map()
154
159
 
155
160
  this.foundVariables = []
156
161
  this.fileRefsFound = []
@@ -158,17 +163,25 @@ class Configorama {
158
163
  // Track variable resolutions for metadata (keyed by path)
159
164
  this.resolutionTracking = {}
160
165
 
161
- const defaultSyntax = buildVariableSyntax('${', '}', ['AWS', 'stageVariables'])
166
+ // Detect file type early to determine default syntax
167
+ let detectedFileType = null
168
+ if (typeof fileOrObject === 'string') {
169
+ detectedFileType = path.extname(fileOrObject).toLowerCase()
170
+ }
171
+
172
+ // Use $[...] syntax for HCL/Terraform files to avoid conflicts with Terraform's ${} syntax
173
+ const isHclFile = detectedFileType === '.tf' || detectedFileType === '.hcl'
174
+ const defaultSyntax = isHclFile
175
+ ? buildVariableSyntax('$[', ']', ['AWS', 'stageVariables'])
176
+ : buildVariableSyntax('${', '}', ['AWS', 'stageVariables'])
162
177
 
163
178
  const varSyntax = options.syntax || defaultSyntax
164
179
  let varRegex
165
180
  if (typeof varSyntax === 'string') {
166
181
  varRegex = new RegExp(varSyntax, 'g')
167
- // this.variableSyntax = /\${((?!AWS)([ ~:a-zA-Z0-9=+!@#%*<>?._'",|\-\/\(\)\\]+?|(\w+)\s*\(((?:[^()]+)*)?\s*\)\s*))}/
168
182
  } else if (varSyntax instanceof RegExp) {
169
183
  varRegex = varSyntax
170
184
  }
171
- // console.log('varRegex', varRegex)
172
185
  const variableSyntax = varRegex
173
186
  this.variableSyntax = variableSyntax
174
187
 
@@ -183,10 +196,15 @@ class Configorama {
183
196
 
184
197
  // Set initial config object to populate
185
198
  if (typeof fileOrObject === 'object') {
199
+ // Store truly raw config before any preprocessing
200
+ this.rawOriginalConfig = cloneDeep(fileOrObject)
201
+ // Preprocess: convert bare refs in if(), escape help() args
202
+ // Skip fallback fixing for object configs (they handle bare refs differently)
203
+ const processed = preProcess(fileOrObject, this.variableSyntax, this.variableTypes, { skipFallbackFix: true })
186
204
  // set config objects
187
- this.config = fileOrObject
205
+ this.config = processed
188
206
  // Keep a copy
189
- this.originalConfig = cloneDeep(fileOrObject)
207
+ this.originalConfig = cloneDeep(processed)
190
208
  // Set configPath for file references
191
209
  this.configPath = options.configDir || process.cwd()
192
210
  } else if (typeof fileOrObject === 'string') {
@@ -251,6 +269,13 @@ class Configorama {
251
269
  */
252
270
  getValueFromEval,
253
271
 
272
+ /**
273
+ * If expressions (alias for eval)
274
+ * Usage:
275
+ * ${if(${self:value} > 10 ? "big" : "small")}
276
+ */
277
+ getValueFromIf,
278
+
254
279
  /**
255
280
  * Self references
256
281
  * Usage:
@@ -395,6 +420,15 @@ class Configorama {
395
420
  )
396
421
  this.variablesKnownTypes = variablesKnownTypes
397
422
 
423
+ // Build prefix lookup map for O(1) type detection (perf optimization)
424
+ this._resolverByPrefix = new Map()
425
+ for (const r of this.variableTypes) {
426
+ const prefix = r.prefix || r.type
427
+ if (prefix && r.match instanceof RegExp && !r.internal) {
428
+ this._resolverByPrefix.set(prefix + ':', r)
429
+ }
430
+ }
431
+
398
432
  // this.allPatterns = combineRegexes(...this.variableTypes.map((v) => v.match))
399
433
  // console.log('this.allPatterns', this.allPatterns)
400
434
  // console.log('this.variablesKnownTypes', this.variablesKnownTypes)
@@ -584,19 +618,26 @@ class Configorama {
584
618
  */
585
619
  isUnknownTypeAllowed(varString) {
586
620
  const setting = this.settings.allowUnknownVariableTypes
587
- if (setting === true) return true
588
621
  if (setting === false || setting === undefined) return false
622
+
623
+ // Extract type prefix from variable string
624
+ // Handle both 'ssm:path' and '${ssm:path}' formats
625
+ let cleanVar = varString
626
+ if (cleanVar.startsWith(this.varPrefix)) {
627
+ cleanVar = cleanVar.slice(this.varPrefix.length)
628
+ }
629
+ if (cleanVar.endsWith(this.varSuffix)) {
630
+ cleanVar = cleanVar.slice(0, -this.varSuffix.length)
631
+ }
632
+ const typePrefix = this.extractTypePrefix(cleanVar)
633
+
634
+ // Check if this is a known type (has a resolver) - known types should not be treated as "unknown allowed"
635
+ const isKnownType = typePrefix && this._resolverByPrefix && this._resolverByPrefix.has(typePrefix + ':')
636
+ if (isKnownType) return false
637
+
638
+ if (setting === true) return true
639
+
589
640
  if (Array.isArray(setting)) {
590
- // Extract type prefix from variable string
591
- // Handle both 'ssm:path' and '${ssm:path}' formats
592
- let cleanVar = varString
593
- if (cleanVar.startsWith(this.varPrefix)) {
594
- cleanVar = cleanVar.slice(this.varPrefix.length)
595
- }
596
- if (cleanVar.endsWith(this.varSuffix)) {
597
- cleanVar = cleanVar.slice(0, -this.varSuffix.length)
598
- }
599
- const typePrefix = this.extractTypePrefix(cleanVar)
600
641
  if (typePrefix && setting.includes(typePrefix)) return true
601
642
  }
602
643
  return false
@@ -1234,6 +1275,7 @@ class Configorama {
1234
1275
  const transform = this.runFunction.bind(this)
1235
1276
  const varSyntax = this.variableSyntax
1236
1277
  const leaves = this.leaves
1278
+ const filters = this.filters
1237
1279
  // console.log('leaves two', leaves)
1238
1280
  // Traverse resolved object and run functions
1239
1281
  // console.log('this.config', this.config)
@@ -1268,17 +1310,59 @@ class Configorama {
1268
1310
  // console.log('funcString', funcString)
1269
1311
  const func = cleanVariable(funcString, varSyntax, true, `init ${this.callCount}`)
1270
1312
  const funcVal = transform(func)
1271
- const hasObjectRef = rawValue.match(/\.\S*$/)
1272
- if (hasObjectRef && typeof funcVal === 'object') {
1273
- const objectPath = hasObjectRef[0].replace(/^\./, '')
1274
- // console.log('objectPath', objectPath)
1275
- /* get value from object and update */
1276
- const valueFromObject = dotProp.get(funcVal, objectPath)
1277
- // console.log('valueFromObject', valueFromObject)
1278
- this.update(valueFromObject)
1313
+
1314
+ // Strip filters like " | toUpperCase" before checking for property/index access
1315
+ const rawValueNoFilters = rawValue.replace(/\s*\|.*$/, '')
1316
+
1317
+ // Helper to get property from value (works on objects, arrays, and primitives)
1318
+ const getProp = (val, path) => {
1319
+ if (val == null) return undefined
1320
+ // For primitives (string, number), access property directly
1321
+ if (typeof val !== 'object') {
1322
+ // Handle single property like 'length'
1323
+ if (!path.includes('.')) return val[path]
1324
+ // Handle path like 'foo.bar' - not applicable for primitives
1325
+ return undefined
1326
+ }
1327
+ return dotProp.get(val, path)
1328
+ }
1329
+
1330
+ // Extract filters from rawValue if present (may end with } from ${...})
1331
+ // Handles multiple filters like "| trim | toUpperCase"
1332
+ const pipeIdx = rawValue.indexOf('|')
1333
+ const filterNames = pipeIdx > -1
1334
+ ? splitOnPipe(rawValue.slice(pipeIdx).replace(/\}$/, ''))
1335
+ .map(f => f.trim().split('(')[0])
1336
+ .filter(Boolean)
1337
+ : []
1338
+
1339
+ let finalValue = funcVal
1340
+
1341
+ // Check for array index access: [N] optionally followed by .property
1342
+ const indexMatch = rawValueNoFilters.match(/[)\}]\s*\[(\d+)\](?:\.([\w.]+))?$/)
1343
+ if (indexMatch && Array.isArray(funcVal)) {
1344
+ const index = parseInt(indexMatch[1], 10)
1345
+ const propPath = indexMatch[2]
1346
+ finalValue = funcVal[index]
1347
+ if (propPath && finalValue != null) {
1348
+ finalValue = getProp(finalValue, propPath)
1349
+ }
1279
1350
  } else {
1280
- this.update(funcVal)
1351
+ // Check for property access: .foo.bar after function close
1352
+ const propMatch = rawValueNoFilters.match(/[)\}]\s*\.([\w.]+)$/)
1353
+ if (propMatch && typeof funcVal === 'object') {
1354
+ finalValue = dotProp.get(funcVal, propMatch[1])
1355
+ }
1356
+ }
1357
+
1358
+ // Apply filters in sequence
1359
+ for (const filterName of filterNames) {
1360
+ if (filters[filterName]) {
1361
+ finalValue = filters[filterName](finalValue)
1362
+ }
1281
1363
  }
1364
+
1365
+ this.update(finalValue)
1282
1366
  }
1283
1367
 
1284
1368
  /* fix for file(JS-ref.js, raw) to keep parens and inline code */
@@ -1375,8 +1459,7 @@ class Configorama {
1375
1459
  if (hasFilters) {
1376
1460
  // Extract filter names from the match (e.g., "| String}" -> ["String"])
1377
1461
  const filterPart = hasFilters[0].replace(/}?$/, '') // Remove trailing }
1378
- foundFilters = filterPart
1379
- .split('|')
1462
+ foundFilters = splitOnPipe(filterPart)
1380
1463
  .map((filter) => filter.trim())
1381
1464
  .filter(Boolean)
1382
1465
 
@@ -1844,21 +1927,35 @@ class Configorama {
1844
1927
  const thePath = leaf.path.length > 1 ? leaf.path.join('.') : leaf.path[0]
1845
1928
  // console.log('thePath', thePath)
1846
1929
  // console.log('this.originalConfig', this.originalConfig)
1847
- let originalValue = dotProp.get(this.originalConfig, thePath)
1848
- // TODO @DWELLS make recursive
1849
- if (!originalValue) {
1850
- // Recurse up the tree until we find a value
1851
- let currentPathArray = leaf.path.slice(0, -1)
1852
- while (currentPathArray.length > 0 && !originalValue) {
1853
- const currentPath = currentPathArray.length > 1 ? currentPathArray.join('.') : currentPathArray[0]
1854
- // console.log('checking parent path:', currentPath)
1855
- originalValue = dotProp.get(this.originalConfig, currentPath)
1856
- if (typeof originalValue !== 'undefined') {
1857
- leaf.originalValuePath = currentPath
1858
- leaf.currentConfig = this.config
1930
+
1931
+ // Check cache first (perf: avoid repeated dotProp.get calls)
1932
+ let originalValue
1933
+ let originalValuePath
1934
+ if (this._originalValueCache.has(thePath)) {
1935
+ const cached = this._originalValueCache.get(thePath)
1936
+ originalValue = cached.value
1937
+ originalValuePath = cached.originalValuePath
1938
+ } else {
1939
+ originalValue = dotProp.get(this.originalConfig, thePath)
1940
+ // TODO @DWELLS make recursive
1941
+ if (!originalValue) {
1942
+ // Recurse up the tree until we find a value
1943
+ // Use index instead of slice() to avoid array allocations
1944
+ for (let pathLen = leaf.path.length - 1; pathLen > 0 && !originalValue; pathLen--) {
1945
+ const currentPath = leaf.path.slice(0, pathLen).join('.')
1946
+ // console.log('checking parent path:', currentPath)
1947
+ originalValue = dotProp.get(this.originalConfig, currentPath)
1948
+ if (typeof originalValue !== 'undefined') {
1949
+ originalValuePath = currentPath
1950
+ }
1859
1951
  }
1860
- currentPathArray = currentPathArray.slice(0, -1)
1861
1952
  }
1953
+ // Cache the result
1954
+ this._originalValueCache.set(thePath, { value: originalValue, originalValuePath })
1955
+ }
1956
+ if (originalValuePath) {
1957
+ leaf.originalValuePath = originalValuePath
1958
+ leaf.currentConfig = this.config
1862
1959
  }
1863
1960
  leaf.originalSource = originalValue
1864
1961
 
@@ -1995,6 +2092,7 @@ class Configorama {
1995
2092
  // Initialize resolution history if needed
1996
2093
  if (!valueObject.resolutionHistory) {
1997
2094
  valueObject.resolutionHistory = []
2095
+ valueObject._historyKeys = new Set()
1998
2096
  }
1999
2097
 
2000
2098
  let result = valueObject.value
@@ -2113,12 +2211,13 @@ class Configorama {
2113
2211
  }
2114
2212
 
2115
2213
  // Only add to history if not a duplicate (same match + variable)
2116
- const isDuplicate = valueObject.resolutionHistory.some(entry =>
2117
- entry.match === historyEntry.match &&
2118
- entry.variable === historyEntry.variable
2119
- )
2120
-
2121
- if (!isDuplicate) {
2214
+ // Use Set for O(1) lookup instead of O(n) array scan
2215
+ const historyKey = `${historyEntry.match}|${historyEntry.variable}`
2216
+ if (!valueObject._historyKeys) {
2217
+ valueObject._historyKeys = new Set()
2218
+ }
2219
+ if (!valueObject._historyKeys.has(historyKey)) {
2220
+ valueObject._historyKeys.add(historyKey)
2122
2221
  valueObject.resolutionHistory.push(historyEntry)
2123
2222
  }
2124
2223
 
@@ -2277,9 +2376,8 @@ class Configorama {
2277
2376
  const hasFilters = originalSrc.match(this.filterMatch)
2278
2377
  let foundFilters = []
2279
2378
  if (hasFilters) {
2280
- foundFilters = hasFilters[0]
2281
- .replace(this.varSuffixPattern, '')
2282
- .split('|')
2379
+ const filterPart = hasFilters[0].replace(this.varSuffixPattern, '')
2380
+ foundFilters = splitOnPipe(filterPart)
2283
2381
  .map((filter) => filter.trim())
2284
2382
  .filter(Boolean)
2285
2383
  }
@@ -2325,7 +2423,9 @@ class Configorama {
2325
2423
  let deepIndex = Number(v.match(deepIndexPattern)[1])
2326
2424
  let item = this.deep[deepIndex]
2327
2425
 
2328
- if (item.match(deepRefSyntax)) {
2426
+ // Only follow chain if item IS a deep ref (not just contains one)
2427
+ // e.g. item = "${deep:0}" should follow, but item = "https://...${deep:0}..." should not
2428
+ if (/^\$\{deep:\d+\}$/.test(item)) {
2329
2429
  deepIndex = Number(item.match(deepIndexPattern)[1])
2330
2430
  item = this.deep[deepIndex]
2331
2431
  }
@@ -2372,6 +2472,18 @@ class Configorama {
2372
2472
  valueToPopulate = valueToPopulate.replace(this.varSuffixPattern, '')
2373
2473
  }
2374
2474
 
2475
+ // For eval/if expressions, string values need quotes unless already quoted
2476
+ // BUT don't quote strings that contain variable refs (they need further resolution)
2477
+ if (/\b(eval|if)\s*\(/.test(property) && !valueToPopulate.match(this.variableSyntax)) {
2478
+ const matchIdx = property.indexOf(currentMatchedString)
2479
+ const charBefore = matchIdx > 0 ? property[matchIdx - 1] : ''
2480
+ // Always escape quotes in values for eval/if context
2481
+ valueToPopulate = valueToPopulate.replace(/"/g, '\\"')
2482
+ if (charBefore !== '"' && charBefore !== "'") {
2483
+ // Not already quoted, wrap in quotes for eval
2484
+ valueToPopulate = `"${valueToPopulate}"`
2485
+ }
2486
+ }
2375
2487
  property = replaceAll(currentMatchedString, valueToPopulate, property)
2376
2488
  // console.log('property replaceAll', property)
2377
2489
 
@@ -2387,37 +2499,55 @@ class Configorama {
2387
2499
  // } else if (isArray(valueToPopulate) && valueToPopulate.length === 1) {
2388
2500
  // property = replaceAll(matchedString, String(valueToPopulate[0]), property)
2389
2501
  } else if (isObject(valueToPopulate)) {
2390
- if (DEBUG_TYPE) console.log('DEBUG_TYPEisObject')
2391
-
2392
- const objStr = JSON.stringify(valueToPopulate)
2393
- /* Check if variable inside another variable. E.g. ${env:${self:someObject}} that resolves to ${env:{...}} */
2394
- const isNestedInVariable = (
2395
- property.trim() !== matchedString.trim() &&
2396
- property.indexOf(matchedString) !== -1 &&
2397
- matchedString.match(this.variableSyntax) &&
2398
- property.match(this.variableSyntax)
2399
- )
2400
- // Only encode for file() or text() references where JSON braces break regex matching
2401
- const isFileOrTextRef = /\bfile\s*\(|\btext\s*\(/.test(property)
2402
- if (isNestedInVariable && isFileOrTextRef) {
2403
- // Encode object as base64 to avoid breaking variable syntax with nested braces
2404
- const encodedObj = encodeJsonForVariable(valueToPopulate)
2405
- property = replaceAll(matchedString, encodedObj, property)
2406
- } else if (isNestedInVariable) {
2407
- const isVar = /^\${[a-zA-Z0-9_]+:/.test(property)
2408
- if (isVar) {
2409
- throw new Error(
2410
- `Invalid variable syntax "${property}" resolves to "${replaceAll(matchedString, objStr, property)}"`,
2411
- )
2412
- }
2413
- property = replaceAll(matchedString, objStr, property)
2502
+ if (DEBUG_TYPE) console.log('DEBUG_TYPE isObject')
2503
+
2504
+ // For eval/if expressions, encode objects to avoid {} breaking variable syntax
2505
+ const isEvalOrIf = /\b(eval|if)\s*\(/.test(property)
2506
+ if (isEvalOrIf) {
2507
+ const encoded = encodeValueForEval(valueToPopulate)
2508
+ property = replaceAll(matchedString, encoded, property)
2414
2509
  } else {
2415
- // console.log('OBJECT MATCH', `"${objStr}"`)
2416
- property = replaceAll(matchedString, objStr, property)
2510
+ const objStr = JSON.stringify(valueToPopulate)
2511
+ /* Check if variable inside another variable. E.g. ${env:${self:someObject}} that resolves to ${env:{...}} */
2512
+ const isNestedInVariable = (
2513
+ property.trim() !== matchedString.trim() &&
2514
+ property.indexOf(matchedString) !== -1 &&
2515
+ matchedString.match(this.variableSyntax) &&
2516
+ property.match(this.variableSyntax)
2517
+ )
2518
+ // Only encode for file() or text() references where JSON braces break regex matching
2519
+ const isFileOrTextRef = /\bfile\s*\(|\btext\s*\(/.test(property)
2520
+ if (isNestedInVariable && isFileOrTextRef) {
2521
+ // Encode object as base64 to avoid breaking variable syntax with nested braces
2522
+ const encodedObj = encodeJsonForVariable(valueToPopulate)
2523
+ property = replaceAll(matchedString, encodedObj, property)
2524
+ } else if (isNestedInVariable) {
2525
+ const isVar = /^\${[a-zA-Z0-9_]+:/.test(property)
2526
+ if (isVar) {
2527
+ throw new Error(
2528
+ `Invalid variable syntax "${property}" resolves to "${replaceAll(matchedString, objStr, property)}"`,
2529
+ )
2530
+ }
2531
+ property = replaceAll(matchedString, objStr, property)
2532
+ } else {
2533
+ // console.log('OBJECT MATCH', `"${objStr}"`)
2534
+ property = replaceAll(matchedString, objStr, property)
2535
+ }
2417
2536
  }
2418
2537
  // console.log('property', property)
2419
2538
  // TODO run functions here
2420
2539
  // console.log('other new prop', property)
2540
+
2541
+ // partial replacement, boolean inside eval/if expressions
2542
+ } else if (typeof valueToPopulate === 'boolean' && /\b(eval|if)\s*\(/.test(property)) {
2543
+ if (DEBUG_TYPE) console.log('DEBUG_TYPE isBoolean in eval/if')
2544
+ property = replaceAll(matchedString, String(valueToPopulate), property)
2545
+
2546
+ // partial replacement, null inside eval/if expressions
2547
+ } else if (valueToPopulate === null && /\b(eval|if)\s*\(/.test(property)) {
2548
+ if (DEBUG_TYPE) console.log('DEBUG_TYPE isNull in eval/if')
2549
+ property = replaceAll(matchedString, '__NULL__', property)
2550
+
2421
2551
  } else {
2422
2552
  if (DEBUG_TYPE) console.log('DEBUG_TYPE else')
2423
2553
  let missingValue = matchedString
@@ -2434,7 +2564,7 @@ class Configorama {
2434
2564
  true,
2435
2565
  `populateVariable fallback ${this.callCount}`
2436
2566
  )
2437
- const cleanVarNoFilters = cleanVar.split('|')[0]
2567
+ const cleanVarNoFilters = splitOnPipe(cleanVar)[0]
2438
2568
  const splitVars = splitByComma(cleanVarNoFilters)
2439
2569
  const nestedVar = findNestedVariable(splitVars, valueObject.originalSource)
2440
2570
 
@@ -2559,8 +2689,9 @@ Missing Value ${missingValue} - ${matchedString}
2559
2689
  /* Not file or text refs */
2560
2690
  !prop.match(fileRefSyntax)
2561
2691
  && !prop.match(textRefSyntax)
2562
- /* Not eval refs */
2563
- && !prop.match(getValueFromEval.match)
2692
+ /* Not eval/if refs */
2693
+ && !prop.match(getValueFromEval.match)
2694
+ && !prop.match(getValueFromIf.match)
2564
2695
  // AND is not multiline value
2565
2696
  && (func && prop.split('\n').length < 3)) {
2566
2697
  // console.log('IS FUNCTION')
@@ -2821,12 +2952,11 @@ Missing Value ${missingValue} - ${matchedString}
2821
2952
  promiseKey = deeperValue.match(/\s\|/) ? deeperValue : undefined
2822
2953
 
2823
2954
  // TODO clean this up
2824
- const t = variableString.split('|')
2955
+ const t = splitOnPipe(variableString)
2825
2956
  // console.log('variableString', variableString)
2826
2957
  // console.log('valueObject', valueObject)
2827
2958
  // console.log('t', t)
2828
- const _filter = string
2829
- .split('|')
2959
+ const _filter = splitOnPipe(string)
2830
2960
  .filter((value, index, arr) => {
2831
2961
  return index > 0
2832
2962
  })
@@ -2849,24 +2979,40 @@ Missing Value ${missingValue} - ${matchedString}
2849
2979
  /** @type {Function|undefined} */
2850
2980
  let resolverFunction
2851
2981
  let resolverType
2852
- /* Loop over variables and set getterFunction when match found. */
2853
- const found = this.variableTypes.some((r, i) => {
2854
- if (r.match instanceof RegExp && variableString.match(r.match)) {
2855
- // set resolver function
2856
- resolverFunction = r.resolver
2857
- resolverType = r.type || 'unknown'
2858
- return true
2859
- } else if (typeof r.match === 'function') {
2860
- // TODO finalize match API
2861
- if (r.match(variableString, this.config, valueObject)) {
2982
+ let found = false
2983
+
2984
+ // Fast path: try prefix lookup first for O(1) detection of common types
2985
+ const colonIdx = variableString.indexOf(':')
2986
+ if (colonIdx !== -1) {
2987
+ const prefix = variableString.slice(0, colonIdx + 1)
2988
+ const resolver = this._resolverByPrefix.get(prefix)
2989
+ if (resolver && resolver.match instanceof RegExp && variableString.match(resolver.match)) {
2990
+ resolverFunction = resolver.resolver
2991
+ resolverType = resolver.type || 'unknown'
2992
+ found = true
2993
+ }
2994
+ }
2995
+
2996
+ // Fallback: loop over all variable types
2997
+ if (!found) {
2998
+ found = this.variableTypes.some((r, i) => {
2999
+ if (r.match instanceof RegExp && variableString.match(r.match)) {
2862
3000
  // set resolver function
2863
3001
  resolverFunction = r.resolver
2864
3002
  resolverType = r.type || 'unknown'
2865
3003
  return true
3004
+ } else if (typeof r.match === 'function') {
3005
+ // TODO finalize match API
3006
+ if (r.match(variableString, this.config, valueObject)) {
3007
+ // set resolver function
3008
+ resolverFunction = r.resolver
3009
+ resolverType = r.type || 'unknown'
3010
+ return true
3011
+ }
2866
3012
  }
2867
- }
2868
- return false
2869
- })
3013
+ return false
3014
+ })
3015
+ }
2870
3016
  /*
2871
3017
  // console.log('found variable resolver', found)
2872
3018
  // console.log('resolverFunction', resolverFunction)
@@ -2907,8 +3053,10 @@ Missing Value ${missingValue} - ${matchedString}
2907
3053
  }
2908
3054
 
2909
3055
  // console.log('VALUE', val)
3056
+ // For eval/if resolvers, null is a valid intentional result (e.g., ternary false branch)
3057
+ const isEvalOrIfResolver = resolverType === 'eval' || resolverType === 'if'
2910
3058
  if (
2911
- val === null ||
3059
+ (val === null && !isEvalOrIfResolver) ||
2912
3060
  typeof val === 'undefined' ||
2913
3061
  /* match deep refs as empty {}, they need resolving via functions */
2914
3062
  (typeof val === 'object' && isEmpty(val) && variableString.match(/deep\:/))
@@ -2966,7 +3114,8 @@ Missing Value ${missingValue} - ${matchedString}
2966
3114
  return Promise.resolve(undefined)
2967
3115
  }
2968
3116
  }
2969
- return Promise.resolve(encodeUnknown(propertyString))
3117
+ // Encode only the unknown variable, not the entire string
3118
+ return Promise.resolve(encodeUnknown(this.varPrefix + variableString + this.varSuffix))
2970
3119
  }
2971
3120
 
2972
3121
  if (valueCount.length === 1 && noNestedVars) {
@@ -3040,9 +3189,8 @@ Missing Value ${missingValue} - ${matchedString}
3040
3189
 
3041
3190
  if (typeof val === 'string' && val.match(/deep:/)) {
3042
3191
  // TODO refactor the deep filter logic here. match | filter | filter..
3043
- const allFilters = propertyString
3044
- .replace(this.varSuffixPattern, '')
3045
- .split('|')
3192
+ const propWithoutSuffix = propertyString.replace(this.varSuffixPattern, '')
3193
+ const allFilters = splitOnPipe(propWithoutSuffix)
3046
3194
  .reduce((acc, currentFilter, i) => {
3047
3195
  if (i === 0) {
3048
3196
  return acc
@@ -3108,7 +3256,7 @@ Missing Value ${missingValue} - ${matchedString}
3108
3256
  // TODO @DWELLS cleanVariable makes fallback values with spaces have no spaces
3109
3257
  // console.log('AFTER cleanVariable', clean)
3110
3258
  // console.log(typeof clean)
3111
- const cleanClean = clean.split('|')[0]
3259
+ const cleanClean = splitOnPipe(clean)[0]
3112
3260
  // console.log('cleanCleanVariable', cleanClean)
3113
3261
  if (funcRegex.exec(cleanClean)) {
3114
3262
  const valuePromise = Promise.resolve(cleanClean)
@@ -3238,10 +3386,13 @@ Missing Value ${missingValue} - ${matchedString}
3238
3386
  // console.log('allowUnknownVars propertyString', propertyString)
3239
3387
  const varMatches = propertyString.match(this.variableSyntax)
3240
3388
  let allowUnknownVars = propertyString
3241
- /* If variables found, encode them for passthrough */
3389
+ /* Only encode variables that are actually unknown, not all of them */
3242
3390
  if (varMatches && varMatches.length) {
3243
3391
  varMatches.forEach((m) => {
3244
- allowUnknownVars = allowUnknownVars.replace(m, encodeUnknown(m))
3392
+ // Only encode this variable if IT is unknown (not just because the string contains unknowns)
3393
+ if (this.isUnknownTypeAllowed(m)) {
3394
+ allowUnknownVars = allowUnknownVars.replace(m, encodeUnknown(m))
3395
+ }
3245
3396
  })
3246
3397
  }
3247
3398
  // console.log('allowUnknownVars propertyString:', propertyString)
@@ -3298,7 +3449,9 @@ Missing Value ${missingValue} - ${matchedString}
3298
3449
  config: this.config,
3299
3450
  getDeeperValue: this.getDeeperValue.bind(this),
3300
3451
  fileRefSyntax: fileRefSyntax,
3301
- textRefSyntax: textRefSyntax
3452
+ textRefSyntax: textRefSyntax,
3453
+ varPrefix: this.varPrefix,
3454
+ varSuffix: this.varSuffix
3302
3455
  }
3303
3456
  return getValueFromFileResolver(ctx, variableString, options)
3304
3457
  }
@@ -3460,7 +3613,17 @@ Missing Value ${missingValue} - ${matchedString}
3460
3613
  var hasFunc = funcRegex.exec(variableString)
3461
3614
  // TODO finish Function handling. Need to move this down below resolver to resolve inner refs first
3462
3615
  // console.log('hasFunc', hasFunc)
3463
- if (!hasFunc || hasFunc && (hasFunc[1] === 'cron' || hasFunc[1] === 'eval')) {
3616
+ // Skip special expressions (cron, eval, if) - these aren't user functions
3617
+ if (!hasFunc || hasFunc && (hasFunc[1] === 'cron' || hasFunc[1] === 'eval' || hasFunc[1] === 'if')) {
3618
+ return variableString
3619
+ }
3620
+ // Skip file/text when they match resolver regex OR contain encoded passthrough values
3621
+ // Malformed patterns (with %, \, etc) should still error
3622
+ const hasPassthrough = variableString.includes('>passthrough')
3623
+ if (hasFunc[1] === 'file' && (variableString.match(fileRefSyntax) || hasPassthrough)) {
3624
+ return variableString
3625
+ }
3626
+ if (hasFunc[1] === 'text' && (variableString.match(textRefSyntax) || hasPassthrough)) {
3464
3627
  return variableString
3465
3628
  }
3466
3629
  // test for object
@@ -19,13 +19,6 @@ async function executeESMFile(filePath, opts = {}) {
19
19
  const resolvedPath = path.resolve(filePath)
20
20
  let esmModule = jiti(resolvedPath)
21
21
 
22
- // Handle different export patterns - jiti returns { default: Function } for ESM default exports
23
- if (esmModule && typeof esmModule === 'object' && esmModule.default) {
24
- esmModule = esmModule.default
25
- }
26
-
27
- // For ESM files, we just return the module (object or function)
28
- // The calling code will determine whether to execute it or not
29
22
  return esmModule
30
23
  } catch (err) {
31
24
  throw new Error(`Failed to load ESM file ${filePath}: ${err.message}`)
@@ -50,13 +43,6 @@ function executeESMFileSync(filePath, opts = {}) {
50
43
  const resolvedPath = path.resolve(filePath)
51
44
  let esmModule = jiti(resolvedPath)
52
45
 
53
- // Handle different export patterns - jiti returns { default: Function } for ESM default exports
54
- if (esmModule && typeof esmModule === 'object' && esmModule.default) {
55
- esmModule = esmModule.default
56
- }
57
-
58
- // For ESM files, we just return the module (object or function)
59
- // The calling code will determine whether to execute it or not
60
46
  return esmModule
61
47
  } catch (err) {
62
48
  throw new Error(`Failed to load ESM file ${filePath}: ${err.message}`)