configorama 0.9.8 → 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 (50) hide show
  1. package/README.md +83 -0
  2. package/package.json +1 -1
  3. package/src/main.js +254 -101
  4. package/src/parsers/esm.js +0 -14
  5. package/src/parsers/typescript.js +0 -10
  6. package/src/resolvers/valueFromEval.js +69 -11
  7. package/src/resolvers/valueFromFile.js +1 -1
  8. package/src/resolvers/valueFromIf.js +75 -0
  9. package/src/resolvers/valueFromIf.test.js +66 -0
  10. package/src/resolvers/valueFromNumber.js +3 -0
  11. package/src/utils/handleSignalEvents.js +3 -4
  12. package/src/utils/lodash.js +18 -7
  13. package/src/utils/parsing/cloudformationSchema.js +1 -2
  14. package/src/utils/parsing/cloudformationSchema.test.js +14 -0
  15. package/src/utils/parsing/preProcess.js +220 -5
  16. package/src/utils/paths/getFullFilePath.js +6 -2
  17. package/src/utils/paths/getFullFilePath.test.js +18 -0
  18. package/src/utils/regex/index.js +18 -3
  19. package/src/utils/regex/index.test.js +24 -0
  20. package/src/utils/strings/quoteAware.js +141 -0
  21. package/src/utils/strings/replaceAll.js +13 -1
  22. package/src/utils/strings/splitByComma.js +25 -15
  23. package/src/utils/strings/splitByComma.test.js +19 -0
  24. package/src/utils/strings/splitOnPipe.js +30 -0
  25. package/src/utils/strings/splitOnPipe.test.js +68 -0
  26. package/src/utils/validation/isValidValue.test.js +1 -1
  27. package/src/utils/variables/findNestedVariables.js +8 -2
  28. package/types/src/main.d.ts +3 -1
  29. package/types/src/main.d.ts.map +1 -1
  30. package/types/src/parsers/esm.d.ts.map +1 -1
  31. package/types/src/parsers/typescript.d.ts.map +1 -1
  32. package/types/src/resolvers/valueFromEval.d.ts +1 -0
  33. package/types/src/resolvers/valueFromEval.d.ts.map +1 -1
  34. package/types/src/resolvers/valueFromIf.d.ts +7 -0
  35. package/types/src/resolvers/valueFromIf.d.ts.map +1 -0
  36. package/types/src/resolvers/valueFromNumber.d.ts.map +1 -1
  37. package/types/src/utils/handleSignalEvents.d.ts.map +1 -1
  38. package/types/src/utils/lodash.d.ts.map +1 -1
  39. package/types/src/utils/parsing/preProcess.d.ts +5 -1
  40. package/types/src/utils/parsing/preProcess.d.ts.map +1 -1
  41. package/types/src/utils/paths/getFullFilePath.d.ts.map +1 -1
  42. package/types/src/utils/regex/index.d.ts.map +1 -1
  43. package/types/src/utils/strings/quoteAware.d.ts +30 -0
  44. package/types/src/utils/strings/quoteAware.d.ts.map +1 -0
  45. package/types/src/utils/strings/replaceAll.d.ts.map +1 -1
  46. package/types/src/utils/strings/splitByComma.d.ts +1 -1
  47. package/types/src/utils/strings/splitByComma.d.ts.map +1 -1
  48. package/types/src/utils/strings/splitOnPipe.d.ts +8 -0
  49. package/types/src/utils/strings/splitOnPipe.d.ts.map +1 -0
  50. 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 = []
@@ -191,10 +196,15 @@ class Configorama {
191
196
 
192
197
  // Set initial config object to populate
193
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 })
194
204
  // set config objects
195
- this.config = fileOrObject
205
+ this.config = processed
196
206
  // Keep a copy
197
- this.originalConfig = cloneDeep(fileOrObject)
207
+ this.originalConfig = cloneDeep(processed)
198
208
  // Set configPath for file references
199
209
  this.configPath = options.configDir || process.cwd()
200
210
  } else if (typeof fileOrObject === 'string') {
@@ -259,6 +269,13 @@ class Configorama {
259
269
  */
260
270
  getValueFromEval,
261
271
 
272
+ /**
273
+ * If expressions (alias for eval)
274
+ * Usage:
275
+ * ${if(${self:value} > 10 ? "big" : "small")}
276
+ */
277
+ getValueFromIf,
278
+
262
279
  /**
263
280
  * Self references
264
281
  * Usage:
@@ -403,6 +420,15 @@ class Configorama {
403
420
  )
404
421
  this.variablesKnownTypes = variablesKnownTypes
405
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
+
406
432
  // this.allPatterns = combineRegexes(...this.variableTypes.map((v) => v.match))
407
433
  // console.log('this.allPatterns', this.allPatterns)
408
434
  // console.log('this.variablesKnownTypes', this.variablesKnownTypes)
@@ -592,19 +618,26 @@ class Configorama {
592
618
  */
593
619
  isUnknownTypeAllowed(varString) {
594
620
  const setting = this.settings.allowUnknownVariableTypes
595
- if (setting === true) return true
596
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
+
597
640
  if (Array.isArray(setting)) {
598
- // Extract type prefix from variable string
599
- // Handle both 'ssm:path' and '${ssm:path}' formats
600
- let cleanVar = varString
601
- if (cleanVar.startsWith(this.varPrefix)) {
602
- cleanVar = cleanVar.slice(this.varPrefix.length)
603
- }
604
- if (cleanVar.endsWith(this.varSuffix)) {
605
- cleanVar = cleanVar.slice(0, -this.varSuffix.length)
606
- }
607
- const typePrefix = this.extractTypePrefix(cleanVar)
608
641
  if (typePrefix && setting.includes(typePrefix)) return true
609
642
  }
610
643
  return false
@@ -1242,6 +1275,7 @@ class Configorama {
1242
1275
  const transform = this.runFunction.bind(this)
1243
1276
  const varSyntax = this.variableSyntax
1244
1277
  const leaves = this.leaves
1278
+ const filters = this.filters
1245
1279
  // console.log('leaves two', leaves)
1246
1280
  // Traverse resolved object and run functions
1247
1281
  // console.log('this.config', this.config)
@@ -1276,17 +1310,59 @@ class Configorama {
1276
1310
  // console.log('funcString', funcString)
1277
1311
  const func = cleanVariable(funcString, varSyntax, true, `init ${this.callCount}`)
1278
1312
  const funcVal = transform(func)
1279
- const hasObjectRef = rawValue.match(/\.\S*$/)
1280
- if (hasObjectRef && typeof funcVal === 'object') {
1281
- const objectPath = hasObjectRef[0].replace(/^\./, '')
1282
- // console.log('objectPath', objectPath)
1283
- /* get value from object and update */
1284
- const valueFromObject = dotProp.get(funcVal, objectPath)
1285
- // console.log('valueFromObject', valueFromObject)
1286
- 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
+ }
1287
1350
  } else {
1288
- 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
+ }
1289
1363
  }
1364
+
1365
+ this.update(finalValue)
1290
1366
  }
1291
1367
 
1292
1368
  /* fix for file(JS-ref.js, raw) to keep parens and inline code */
@@ -1383,8 +1459,7 @@ class Configorama {
1383
1459
  if (hasFilters) {
1384
1460
  // Extract filter names from the match (e.g., "| String}" -> ["String"])
1385
1461
  const filterPart = hasFilters[0].replace(/}?$/, '') // Remove trailing }
1386
- foundFilters = filterPart
1387
- .split('|')
1462
+ foundFilters = splitOnPipe(filterPart)
1388
1463
  .map((filter) => filter.trim())
1389
1464
  .filter(Boolean)
1390
1465
 
@@ -1852,21 +1927,35 @@ class Configorama {
1852
1927
  const thePath = leaf.path.length > 1 ? leaf.path.join('.') : leaf.path[0]
1853
1928
  // console.log('thePath', thePath)
1854
1929
  // console.log('this.originalConfig', this.originalConfig)
1855
- let originalValue = dotProp.get(this.originalConfig, thePath)
1856
- // TODO @DWELLS make recursive
1857
- if (!originalValue) {
1858
- // Recurse up the tree until we find a value
1859
- let currentPathArray = leaf.path.slice(0, -1)
1860
- while (currentPathArray.length > 0 && !originalValue) {
1861
- const currentPath = currentPathArray.length > 1 ? currentPathArray.join('.') : currentPathArray[0]
1862
- // console.log('checking parent path:', currentPath)
1863
- originalValue = dotProp.get(this.originalConfig, currentPath)
1864
- if (typeof originalValue !== 'undefined') {
1865
- leaf.originalValuePath = currentPath
1866
- 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
+ }
1867
1951
  }
1868
- currentPathArray = currentPathArray.slice(0, -1)
1869
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
1870
1959
  }
1871
1960
  leaf.originalSource = originalValue
1872
1961
 
@@ -2003,6 +2092,7 @@ class Configorama {
2003
2092
  // Initialize resolution history if needed
2004
2093
  if (!valueObject.resolutionHistory) {
2005
2094
  valueObject.resolutionHistory = []
2095
+ valueObject._historyKeys = new Set()
2006
2096
  }
2007
2097
 
2008
2098
  let result = valueObject.value
@@ -2121,12 +2211,13 @@ class Configorama {
2121
2211
  }
2122
2212
 
2123
2213
  // Only add to history if not a duplicate (same match + variable)
2124
- const isDuplicate = valueObject.resolutionHistory.some(entry =>
2125
- entry.match === historyEntry.match &&
2126
- entry.variable === historyEntry.variable
2127
- )
2128
-
2129
- 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)
2130
2221
  valueObject.resolutionHistory.push(historyEntry)
2131
2222
  }
2132
2223
 
@@ -2285,9 +2376,8 @@ class Configorama {
2285
2376
  const hasFilters = originalSrc.match(this.filterMatch)
2286
2377
  let foundFilters = []
2287
2378
  if (hasFilters) {
2288
- foundFilters = hasFilters[0]
2289
- .replace(this.varSuffixPattern, '')
2290
- .split('|')
2379
+ const filterPart = hasFilters[0].replace(this.varSuffixPattern, '')
2380
+ foundFilters = splitOnPipe(filterPart)
2291
2381
  .map((filter) => filter.trim())
2292
2382
  .filter(Boolean)
2293
2383
  }
@@ -2333,7 +2423,9 @@ class Configorama {
2333
2423
  let deepIndex = Number(v.match(deepIndexPattern)[1])
2334
2424
  let item = this.deep[deepIndex]
2335
2425
 
2336
- 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)) {
2337
2429
  deepIndex = Number(item.match(deepIndexPattern)[1])
2338
2430
  item = this.deep[deepIndex]
2339
2431
  }
@@ -2380,6 +2472,18 @@ class Configorama {
2380
2472
  valueToPopulate = valueToPopulate.replace(this.varSuffixPattern, '')
2381
2473
  }
2382
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
+ }
2383
2487
  property = replaceAll(currentMatchedString, valueToPopulate, property)
2384
2488
  // console.log('property replaceAll', property)
2385
2489
 
@@ -2395,37 +2499,55 @@ class Configorama {
2395
2499
  // } else if (isArray(valueToPopulate) && valueToPopulate.length === 1) {
2396
2500
  // property = replaceAll(matchedString, String(valueToPopulate[0]), property)
2397
2501
  } else if (isObject(valueToPopulate)) {
2398
- if (DEBUG_TYPE) console.log('DEBUG_TYPEisObject')
2399
-
2400
- const objStr = JSON.stringify(valueToPopulate)
2401
- /* Check if variable inside another variable. E.g. ${env:${self:someObject}} that resolves to ${env:{...}} */
2402
- const isNestedInVariable = (
2403
- property.trim() !== matchedString.trim() &&
2404
- property.indexOf(matchedString) !== -1 &&
2405
- matchedString.match(this.variableSyntax) &&
2406
- property.match(this.variableSyntax)
2407
- )
2408
- // Only encode for file() or text() references where JSON braces break regex matching
2409
- const isFileOrTextRef = /\bfile\s*\(|\btext\s*\(/.test(property)
2410
- if (isNestedInVariable && isFileOrTextRef) {
2411
- // Encode object as base64 to avoid breaking variable syntax with nested braces
2412
- const encodedObj = encodeJsonForVariable(valueToPopulate)
2413
- property = replaceAll(matchedString, encodedObj, property)
2414
- } else if (isNestedInVariable) {
2415
- const isVar = /^\${[a-zA-Z0-9_]+:/.test(property)
2416
- if (isVar) {
2417
- throw new Error(
2418
- `Invalid variable syntax "${property}" resolves to "${replaceAll(matchedString, objStr, property)}"`,
2419
- )
2420
- }
2421
- 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)
2422
2509
  } else {
2423
- // console.log('OBJECT MATCH', `"${objStr}"`)
2424
- 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
+ }
2425
2536
  }
2426
2537
  // console.log('property', property)
2427
2538
  // TODO run functions here
2428
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
+
2429
2551
  } else {
2430
2552
  if (DEBUG_TYPE) console.log('DEBUG_TYPE else')
2431
2553
  let missingValue = matchedString
@@ -2442,7 +2564,7 @@ class Configorama {
2442
2564
  true,
2443
2565
  `populateVariable fallback ${this.callCount}`
2444
2566
  )
2445
- const cleanVarNoFilters = cleanVar.split('|')[0]
2567
+ const cleanVarNoFilters = splitOnPipe(cleanVar)[0]
2446
2568
  const splitVars = splitByComma(cleanVarNoFilters)
2447
2569
  const nestedVar = findNestedVariable(splitVars, valueObject.originalSource)
2448
2570
 
@@ -2567,8 +2689,9 @@ Missing Value ${missingValue} - ${matchedString}
2567
2689
  /* Not file or text refs */
2568
2690
  !prop.match(fileRefSyntax)
2569
2691
  && !prop.match(textRefSyntax)
2570
- /* Not eval refs */
2571
- && !prop.match(getValueFromEval.match)
2692
+ /* Not eval/if refs */
2693
+ && !prop.match(getValueFromEval.match)
2694
+ && !prop.match(getValueFromIf.match)
2572
2695
  // AND is not multiline value
2573
2696
  && (func && prop.split('\n').length < 3)) {
2574
2697
  // console.log('IS FUNCTION')
@@ -2829,12 +2952,11 @@ Missing Value ${missingValue} - ${matchedString}
2829
2952
  promiseKey = deeperValue.match(/\s\|/) ? deeperValue : undefined
2830
2953
 
2831
2954
  // TODO clean this up
2832
- const t = variableString.split('|')
2955
+ const t = splitOnPipe(variableString)
2833
2956
  // console.log('variableString', variableString)
2834
2957
  // console.log('valueObject', valueObject)
2835
2958
  // console.log('t', t)
2836
- const _filter = string
2837
- .split('|')
2959
+ const _filter = splitOnPipe(string)
2838
2960
  .filter((value, index, arr) => {
2839
2961
  return index > 0
2840
2962
  })
@@ -2857,24 +2979,40 @@ Missing Value ${missingValue} - ${matchedString}
2857
2979
  /** @type {Function|undefined} */
2858
2980
  let resolverFunction
2859
2981
  let resolverType
2860
- /* Loop over variables and set getterFunction when match found. */
2861
- const found = this.variableTypes.some((r, i) => {
2862
- if (r.match instanceof RegExp && variableString.match(r.match)) {
2863
- // set resolver function
2864
- resolverFunction = r.resolver
2865
- resolverType = r.type || 'unknown'
2866
- return true
2867
- } else if (typeof r.match === 'function') {
2868
- // TODO finalize match API
2869
- 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)) {
2870
3000
  // set resolver function
2871
3001
  resolverFunction = r.resolver
2872
3002
  resolverType = r.type || 'unknown'
2873
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
+ }
2874
3012
  }
2875
- }
2876
- return false
2877
- })
3013
+ return false
3014
+ })
3015
+ }
2878
3016
  /*
2879
3017
  // console.log('found variable resolver', found)
2880
3018
  // console.log('resolverFunction', resolverFunction)
@@ -2915,8 +3053,10 @@ Missing Value ${missingValue} - ${matchedString}
2915
3053
  }
2916
3054
 
2917
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'
2918
3058
  if (
2919
- val === null ||
3059
+ (val === null && !isEvalOrIfResolver) ||
2920
3060
  typeof val === 'undefined' ||
2921
3061
  /* match deep refs as empty {}, they need resolving via functions */
2922
3062
  (typeof val === 'object' && isEmpty(val) && variableString.match(/deep\:/))
@@ -2974,7 +3114,8 @@ Missing Value ${missingValue} - ${matchedString}
2974
3114
  return Promise.resolve(undefined)
2975
3115
  }
2976
3116
  }
2977
- 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))
2978
3119
  }
2979
3120
 
2980
3121
  if (valueCount.length === 1 && noNestedVars) {
@@ -3048,9 +3189,8 @@ Missing Value ${missingValue} - ${matchedString}
3048
3189
 
3049
3190
  if (typeof val === 'string' && val.match(/deep:/)) {
3050
3191
  // TODO refactor the deep filter logic here. match | filter | filter..
3051
- const allFilters = propertyString
3052
- .replace(this.varSuffixPattern, '')
3053
- .split('|')
3192
+ const propWithoutSuffix = propertyString.replace(this.varSuffixPattern, '')
3193
+ const allFilters = splitOnPipe(propWithoutSuffix)
3054
3194
  .reduce((acc, currentFilter, i) => {
3055
3195
  if (i === 0) {
3056
3196
  return acc
@@ -3116,7 +3256,7 @@ Missing Value ${missingValue} - ${matchedString}
3116
3256
  // TODO @DWELLS cleanVariable makes fallback values with spaces have no spaces
3117
3257
  // console.log('AFTER cleanVariable', clean)
3118
3258
  // console.log(typeof clean)
3119
- const cleanClean = clean.split('|')[0]
3259
+ const cleanClean = splitOnPipe(clean)[0]
3120
3260
  // console.log('cleanCleanVariable', cleanClean)
3121
3261
  if (funcRegex.exec(cleanClean)) {
3122
3262
  const valuePromise = Promise.resolve(cleanClean)
@@ -3246,10 +3386,13 @@ Missing Value ${missingValue} - ${matchedString}
3246
3386
  // console.log('allowUnknownVars propertyString', propertyString)
3247
3387
  const varMatches = propertyString.match(this.variableSyntax)
3248
3388
  let allowUnknownVars = propertyString
3249
- /* If variables found, encode them for passthrough */
3389
+ /* Only encode variables that are actually unknown, not all of them */
3250
3390
  if (varMatches && varMatches.length) {
3251
3391
  varMatches.forEach((m) => {
3252
- 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
+ }
3253
3396
  })
3254
3397
  }
3255
3398
  // console.log('allowUnknownVars propertyString:', propertyString)
@@ -3470,7 +3613,17 @@ Missing Value ${missingValue} - ${matchedString}
3470
3613
  var hasFunc = funcRegex.exec(variableString)
3471
3614
  // TODO finish Function handling. Need to move this down below resolver to resolve inner refs first
3472
3615
  // console.log('hasFunc', hasFunc)
3473
- 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)) {
3474
3627
  return variableString
3475
3628
  }
3476
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}`)
@@ -55,11 +55,6 @@ async function executeTypeScriptFile(filePath, opts = {}) {
55
55
  }
56
56
  }
57
57
 
58
- // Handle ES module default exports
59
- if (tsFile && typeof tsFile === 'object' && 'default' in tsFile) {
60
- tsFile = tsFile.default
61
- }
62
-
63
58
  return tsFile
64
59
  }
65
60
 
@@ -117,11 +112,6 @@ function executeTypeScriptFileSync(filePath, opts = {}) {
117
112
  }
118
113
  }
119
114
 
120
- // Handle ES module default exports
121
- if (tsFile && typeof tsFile === 'object' && 'default' in tsFile) {
122
- tsFile = tsFile.default
123
- }
124
-
125
115
  return tsFile
126
116
  }
127
117