# @stackone/expressions

## Description

This package can be used to parse and evaluate string expressions with support for variables replacement, functions and operators.

## Requirements

Please check the [root README](../../README.md) for requirements.

## Installation

```bash
# install dependencies
$ pnpm install
```

## Available commands

```bash
# clean build output
$ pnpm clean
```

```bash
# build package
$ pnpm build
```

```bash
# run tests
$ pnpm test
```

```bash
# run tests on watch mode
$ pnpm test:watch
```

```bash
# run linter
$ pnpm lint
```

```bash
# run linter and try to fix any error
$ pnpm lint:fix
```

## API Reference

### evaluate(expression: string, context?: object, options?: { incrementalJsonPath?: boolean })

Evaluates the given expression using the provided context.

- Returns the evaluated result
- Throws an error if the expression is invalid or evaluation fails

```js
evaluate("$.user.name", { user: { name: "John" } }); // Returns "John"
evaluate("x + y", { x: 1, y: 2 }); // Returns 3
```

- Setting incrementalJsonPath iterates through any JSON Path expression to check for failure, starting at the root.
- This exchanges performance for better error messages, useful for AI validation and self-repair.

```js
evaluate(
  "$.user.name",
  { user: { name: "John" } },
  { incrementalJsonPath: true }
); // Returns "John"
evaluate(
  "$.user.details.age",
  { user: { details: { name: "John" } } },
  { incrementalJsonPath: true }
); // Throws Error: "Key 'age' not found at '$.user.details'. Available keys: name"
```

### isValidExpression(expression: string)

Checks if the given expression is valid.

- Returns `true` if the expression is valid
- Returns `false` otherwise

```js
isValidExpression("$.user.name"); // Returns true
isValidExpression("invalid $$$ expression"); // Returns false
```

### safeEvaluate(expression: string, context?: object)

Safely evaluates the expression without throwing errors.

- Returns the evaluated result if successful
- Returns `null` if evaluation fails or the expression is invalid

```js
safeEvaluate("$.user.name", { user: { name: "John" } }); // Returns "John"
safeEvaluate("$ invalid expression", {}); // Returns null
```

## Expression language syntax

There are three types of expressions supported:

### JSON Path Expressions

When the expression starts with `$`, it is treated as a JSON Path expression and will be evaluated as such.

#### JSON Path Syntax

| JSON Path          | Description                                                        |
| ------------------ | ------------------------------------------------------------------ |
| `$`                | The root object                                                    |
| `.`                | Child operator                                                     |
| `@`                | The current object                                                 |
| `*`                | Wildcard. All elements in an array, or all properties of an object |
| `..`               | Recursive descent                                                  |
| `[]`               | Subscript operator                                                 |
| `[,]`              | Union operator (e.g., `$.a[b,c]` for multiple properties)          |
| `[start:end:step]` | Array slice operator                                               |
| `?(expression)`    | Filter expression                                                  |
| `()`               | Script expression                                                  |

Examples:

```js
// Given the context: { user: { name: "John", age: 30 }, "info/email": "info@email.com" }
"$.user.name"; // Returns "John"
"$.user.age"; // Returns 30
"$.user[*]"; // Returns ["John", 30]
"$.user[name,age]"; // Returns ["John", 30] (union operator)
'$["info/email"]'; // Returns "info@email.com"
```

For more information on JSON Path syntax, refer to the [JSONPath Plus documentation](https://github.com/s3u/JSONPath) and the original [JSON Path specification](https://goessner.net/articles/JsonPath/).

### JEXL Expressions

This kind of expression is enclosed in double brackets `{{expression}}`. It supports variables and operators.

#### Operators

| Operator | Description                    |
| -------- | ------------------------------ |
| `!`      | Logical NOT                    |
| `+`      | Addition, string concatenation |
| `-`      | Subtraction                    |
| `*`      | Multiplication                 |
| `/`      | Division                       |
| `//`     | Floor division                 |
| `%`      | Modulus                        |
| `^`      | Exponentiation                 |
| `&&`     | Logical AND                    |
| `\|\|`   | Logical OR                     |
| `==`     | Equal                          |
| `!=`     | Not equal                      |
| `>`      | Greater than                   |
| `>=`     | Greater than or equal          |
| `<`      | Less than                      |
| `<=`     | Less than or equal             |
| `in`     | Element of string or array     |
| `? :`    | Ternary operator               |
| `??`     | Nullish coalescing operator    |

Examples:

```js
// Given the context: { x: 10, y: 5 }
"{{x + y}}"; // Returns 15
"{{x * 2}}"; // Returns 20
"{{x > y}}"; // Returns true
'{{x == 10 ? "yes" : "no"}}'; // Returns "yes"
"{{x in [1, 2, 3]}}"; // Returns false
"{{x != y}}"; // Returns true
"{{x ?? y}}"; // Returns 10
```

#### Identifiers

Identifiers can be used to reference variables in the context.

```js
// Given the context:
// {
//     name: {
//         first: "John",
//         last: "Smith"
//     },
//     jobs: ["Developer", "Designer"]
// }
`{{name.first}}` // Returns "John"
`{{jobs[1]}}`; // Returns "Designer"
```

#### Collections

Collections, or arrays of objects, can be filtered by including a filter expression in brackets.

```js
// Given the context:
// {
//     users: [
//         { name: "John", age: 30 },
//         { name: "Jane", age: 25 }
//     ]
// }
`{{users[.name == "John"].age}}` // Returns 30
`{{users[.age > 25].name}}`; // Returns ["John"]
```

#### Built-in Functions

The expression handler provides several built-in functions you can use in your expressions:

##### Date Functions

###### nextAnniversary(initialDate, format)

Calculates the next anniversary date for a given date.

- `initialDate` (string): The initial date string
- `format` (string): The format of the date string (uses date-fns format)
- Returns: Date object or null

```js
// If today is April 10, 2025, and a birthday is December 25, 2000
"{{nextAnniversary('25/12/2000', 'dd/MM/yyyy')}}";
// Returns Date object for December 25, 2025 (this year's anniversary)

// If today is April 10, 2025, and a birthday is February 15, 1990
"{{nextAnniversary('15/02/1990', 'dd/MM/yyyy')}}";
// Returns Date object for February 15, 2026 (next year's anniversary)
```

###### yearsElapsed(startDate, format, endDate?)

Calculates the number of complete years elapsed between two dates.

- `startDate` (string): The start date string
- `format` (string): The format of the date string (uses date-fns format)
- `endDate` (string, optional): The end date string, defaults to current date if omitted
- Returns: number of years or null

```js
// Calculate years between two specific dates
"{{yearsElapsed('01/01/2015', 'dd/MM/yyyy', '01/01/2025')}}"; // Returns 10

// Calculate years from a date to today (assuming today is April 10, 2025)
"{{yearsElapsed('01/01/2015', 'dd/MM/yyyy')}}"; // Returns 10
```

###### hasPassed(date, format, yearsToAdd?)

Determines if a given date (optionally with added years) has passed.

- `date` (string): The date to check
- `format` (string): The format of the date string (uses date-fns format)
- `yearsToAdd` (number, optional): Number of years to add to the date before comparing
- Returns: boolean

```js
// Check if a date has passed (assuming today is April 10, 2025)
"{{hasPassed('01/01/2020', 'dd/MM/yyyy')}}"; // Returns true

// Check if a date has passed (assuming today is April 10, 2025)
"{{hasPassed('01/01/2026', 'dd/MM/yyyy')}}"; // Returns false

// Check if a date + 5 years has passed (2020 + 5 = 2025)
"{{hasPassed('01/01/2020', 'dd/MM/yyyy', 5)}}";
// Returns true if April 10, 2025 is after January 1, 2025
```

###### now()

Returns the current date and time.

- Returns: string (ISO 8601 format)

```js
// Get the current date and time
"{{now()}}"; // Returns "2025-04-10T12:00:00.000Z" (example)
```

###### unixToIso(value, unit?)

Converts a Unix epoch timestamp to an ISO 8601 date-time string. Use for providers that return epoch timestamps where the unified schema expects a date-time string.

- `value` (number|string): The epoch timestamp, as a number or numeric string
- `unit` (string, optional): `'seconds'` (default) or `'ms'`
- Returns: string (ISO 8601 format), or null for missing, non-numeric, non-finite or out-of-range input, or an unrecognized unit

```js
// Convert epoch seconds (the default unit)
"{{unixToIso(1769518356)}}"; // Returns "2026-01-27T12:52:36.000Z"

// Convert epoch milliseconds
"{{unixToIso(1769518356722, 'ms')}}"; // Returns "2026-01-27T12:52:36.722Z"

// Unparsable input returns null
"{{unixToIso('not-a-number')}}"; // Returns null
```

###### isoToUnix(value, unit?)

Converts a date-time string to a Unix epoch timestamp. The inverse of `unixToIso`, for providers that expect epoch timestamps where the unified schema holds a date-time string. Accepts the same formats as the `datetime_string` typecast, not just ISO 8601. A value carrying no UTC offset is resolved in the host timezone, so prefer offset-bearing input where the exact instant matters.

- `value` (string|Date): The date-time string, or a Date
- `unit` (string, optional): `'seconds'` (default, floored) or `'ms'`
- Returns: number, or null for unparsable input or an unrecognized unit

```js
// Convert to epoch seconds (the default unit), discarding sub-second precision
"{{isoToUnix('2026-01-27T12:52:36.722Z')}}"; // Returns 1769518356

// Convert to epoch milliseconds
"{{isoToUnix('2026-01-27T12:52:36.722Z', 'ms')}}"; // Returns 1769518356722

// Unparsable input returns null
"{{isoToUnix('not-a-date')}}"; // Returns null

// An epoch number is already a Unix timestamp, and is not accepted
"{{isoToUnix(1769518356)}}"; // Returns null
```

###### formatDate(value, formatString)

Formats a date-time input using a format string composed of standard tokens (`yyyy`, `MM`, `dd`, `HH`, `mm`, `ss`, `EEE`, `MMM`, etc.). Thin wrapper around date-fns' `format` normalised through a UTC-anchored Date subclass — output is rendered in UTC so numeric offset tokens (`xxx`, `X`, `O`, `z`) and instant fields are stable across host timezones, including across DST spring-forward boundaries. Locale-sensitive tokens (`EEE`, `MMM`, `a`) currently follow date-fns' default (`en-US`); pinning is tracked as a follow-up. For epoch numbers, compose with `unixToIso` first so the seconds-vs-milliseconds unit is explicit.

- `value` (string|Date): ISO 8601 string or `Date`. Offset-less strings are accepted but resolve in the host timezone — date-only forms (`'01/27/2026'`) land at host-local midnight and can slip to the prior UTC day under UTC+ hosts; naked datetimes (`'2026-01-27T12:52:36'`) preserve the given wall-clock time in the host TZ. Use an explicit offset (`'…Z'`, `'…+05:30'`) or pass a `Date` when the exact instant matters.
- `formatString` (string, required): A format string composed of the tokens documented at the [format token reference](https://date-fns.org/docs/format)
- Returns: string, or null for unparsable input, an invalid format string, or a format containing an unquoted rejected token — see Foot-guns for the reject list

```js
// Date only (YYYY-MM-DD)
"{{formatDate('2026-01-27T12:52:36.722Z', 'yyyy-MM-dd')}}"; // Returns "2026-01-27"

// Time only (HH:mm:ss)
"{{formatDate('2026-01-27T12:52:36.722Z', 'HH:mm:ss')}}"; // Returns "12:52:36"

// SQL-style datetime
"{{formatDate('2026-01-27T12:52:36.722Z', 'yyyy-MM-dd HH:mm:ss')}}"; // Returns "2026-01-27 12:52:36"

// Full ISO 8601 with milliseconds
"{{formatDate('2026-01-27T12:52:36.722Z', \"yyyy-MM-dd'T'HH:mm:ss.SSS'Z'\")}}"; // Returns "2026-01-27T12:52:36.722Z"

// HTTP Date header (RFC 1123)
"{{formatDate('2026-01-27T12:52:36.722Z', \"EEE, dd MMM yyyy HH:mm:ss 'GMT'\")}}"; // Returns "Tue, 27 Jan 2026 12:52:36 GMT"

// Composing with now() to format the current instant
"{{formatDate(now(), 'yyyy-MM-dd')}}"; // e.g. "2026-01-27"

// Composing with unixToIso for epoch inputs
"{{formatDate(unixToIso(1769518356), 'yyyy-MM-dd')}}";        // seconds → "2026-01-27"
"{{formatDate(unixToIso(1769518356000, 'ms'), 'yyyy-MM-dd')}}"; // ms      → "2026-01-27"

// Empty/missing format string returns null
"{{formatDate('2026-01-27T12:52:36.722Z', '')}}"; // Returns null
```

> **Foot-guns**
>
> - **Literal text must be wrapped in single quotes.** Every unquoted letter is treated as a token. `'yyyy-MM-dd at HH:mm'` returns **null** because the unquoted `t` is the epoch-seconds token (see below). Write it as `"yyyy-MM-dd 'at' HH:mm"` → `"2026-01-27 at 12:52"`.
> - **Lowercase `mm` is minutes; uppercase `MM` is month.** `yyyy-mm-dd` silently renders `"2026-52-27"`. Prefer `yyyy-MM-dd`.
> - The `t` and `T` tokens (Unix epoch seconds / milliseconds) are **rejected and return null** — epoch output belongs on the epoch API, not a date formatter. Use `isoToUnix(value)` for seconds or `isoToUnix(value, 'ms')` for milliseconds instead.
> - **Additional rejected tokens** (return `null` up front):
>   - `D`, `DD`, `YY`, `YYYY` — date-fns v4 throw-tokens. Use `d`/`dd` for day of month and `yyyy` for calendar year. The most common trap is `YYYY-MM-DD` (contains both `YYYY` and `DD`) — write `yyyy-MM-dd` instead.
>   - `Y` — bare week-numbering year token. Also nulls the ordinal form `Yo` (whose `Y` sub-token matches the reject set). Use `yyyy` / `yo` for calendar-year forms.
>   - `Do` — day-of-year ordinal (rejects via its bare `D` sub-token). On Feb 1 it renders `"32nd"` when the caller almost certainly meant `do` (day-of-month ordinal, `"1st"`). Use `do` explicitly.
>   - `ppp`, `pppp`, `PPPppp`, `PPPPpppp` — localized long time with zone; date-fns' formatter emits the resolved locale's timezone name/offset for these tokens, so they leak the host TZ regardless of our UTC anchoring. Use raw `xxx`/`X`/`O`/`z` for offset/zone tokens — those are anchored to UTC.
> - Any longer pure `Y+` or `D+` run outside the rejected set (so `YYY`, `YYYYY`, `DDD`, `DDDD`, `DDDDDD`, …) still renders but emits a runtime `console.warn` from date-fns. Prefer the lowercase calendar-year (`yyyy`) and day-of-month (`dd`) forms.
> - For epoch **input**, wrap with `unixToIso(value)` (seconds) or `unixToIso(value, 'ms')` (milliseconds) so the unit is explicit. Passing raw numbers is not supported — there is no safe default between the two conventions.
> - Truly unknown letter tokens (e.g. `'yyyy-MM-dd j'` — `j` is not a valid token) surface as `null`. Typos where the letter *is* a valid non-epoch token (like `mm` above) surface as silent garbage. Quote-escape any literal text.

##### Array Functions

###### includes(array, value)

Checks if an array includes a specific value or all values from another array.

- `array` (array): The array to check
- `value` (any | array): The value(s) to search for in the array
- Returns: boolean

```js
// Check if an array includes a specific value
"{{includes([1, 2, 3], 2)}}"; // Returns true

// Check if an array includes a specific value
"{{includes([1, 2, 3], 5)}}"; // Returns false

// Can be used with context variables
"{{includes($.allowedRoles, $.currentUser.role)}}";

// Check if an array includes all of values of the second array
"{{includes([1, 2, 3, 4], [2, 4])}}"; // Returns true
```

###### includesSome(array, value)

Checks if an array includes at least one value from another array or a single value.

- `array` (array): The array to check
- `value` (any | array): The value(s) to search for in the array
- Returns: boolean

```js
// Check if an array includes at least one value
"{{includesSome([1, 2, 3], 2)}}"; // Returns true

// Check if an array includes at least one value from another array
"{{includesSome([1, 2, 3], [2, 5])}}"; // Returns true

// Check if an array includes at least one value from another array (none match)
"{{includesSome([1, 2, 3], [4, 5])}}"; // Returns false

// Can be used with context variables
"{{includesSome($.allowedRoles, $.currentUser.roles)}}";
```

###### groupBy(array, key)

Groups an array of objects by the value of a specified key. Items with missing or null values for the key are collected under `"__missing__"`.

- `array` (array): Array of objects to group
- `key` (string): The object key to group by
- Returns: object mapping each unique key value to an array of matching items

```js
// Group employees by department
"{{groupBy($.employees, 'dept')}}";
// { eng: [{dept: "eng", ...}, ...], hr: [{dept: "hr", ...}] }

// Group tickets by status
"{{groupBy($.tickets, 'status')}}";
// { open: [...], closed: [...] }
```

###### reduce(array, operation, field?)

Applies an aggregate operation to an array. When `field` is specified, that field is extracted from each object before applying the operation.

- `array` (array): The array to reduce
- `operation` (string): One of `"sum"`, `"avg"`, `"count"`, `"min"`, `"max"`, `"concat"`, `"flatten"`
- `field` (string, optional): Object key to extract values from before reducing
- Returns: the aggregated result, or null if invalid

```js
// Sum numbers
"{{reduce([1, 2, 3], 'sum')}}"; // Returns 6

// Sum a field from objects
"{{reduce($.orders, 'sum', 'total')}}"; // Returns sum of all order totals

// Average scores
"{{reduce($.reviews, 'avg', 'rating')}}"; // Returns average rating

// Count items
"{{reduce($.users, 'count')}}"; // Returns number of users

// Concatenate arrays from a field
"{{reduce($.users, 'concat', 'permissions')}}"; // Returns all permissions merged
```

###### zipObject(keys, values)

Combines two parallel arrays into an object by pairing `keys[i]` with `values[i]`. If the arrays differ in length, the shorter length is used. Non-string keys are skipped.

- `keys` (array): Array of string keys
- `values` (array): Array of values to pair with each key
- Returns: object mapping each key to its corresponding value

```js
// Combine field names and values into an object
"{{zipObject(['name', 'email'], ['John', 'john@example.com'])}}"; // Returns { name: "John", email: "john@example.com" }

// Numeric values
"{{zipObject(['count', 'total'], [5, 100])}}"; // Returns { count: 5, total: 100 }

// From context variables
"{{zipObject($.fieldNames, $.fieldValues)}}";
```

###### pluck(names, source)

Builds a `{name, value}` pair array from a list of field names and a source object, looking up each name on the source. Fills the gap left by `reduce` (fixed aggregate operations only, no lambda) and the absence of a `map`/`forEach` transform — there is no other way to synthesize new per-item objects from a dynamic key list.

- `names` (array): Field names to look up
- `source` (object): Object to read each name's value from
- Returns: array of `{name, value}` objects, one per name; `value` is `null` when the source object has no such own key, or `source` is missing/not an object

Only own properties are read — inherited keys (`toString`, `constructor`, `__proto__`) resolve to `null` rather than leaking prototype members.

```js
// Look up known field names on a fetched record
"{{pluck(['status', 'url'], $.record)}}"; // Returns [{name: "status", value: "Shortlisted"}, {name: "url", value: null}]

// From context variables — e.g. a dynamic list of custom field names discovered from provider metadata
"{{pluck($.customFieldNames, $.steps.fetchRecord.output.data)}}";
```

###### pluckNested(names, source, valuePath, idPath?)

Builds a `{name, value, value_id}` array from a list of field names, where each name's entry on the source object is itself a nested structure — reads `value` (and optionally `value_id`) from a dot-separated path inside that nested entry. Complements `pluck` for shapes where each looked-up value needs one more level of extraction, e.g. a provider's expanded/navigation-property response.

- `names` (array): Field names to look up
- `source` (object): Object whose values (one per name) are the nested structures to read from
- `valuePath` (string): Dot-separated path into each nested structure for `value`
- `idPath` (string, optional): Dot-separated path into each nested structure for `value_id`
- Returns: array of `{name, value, value_id}` objects, one per name; both are `null` when the source has no such own key or the path doesn't resolve

Only own properties are read, at every level of the path — inherited keys (`toString`, `constructor`, `__proto__`) resolve to `null` rather than leaking prototype members.

```js
// SAP-style expanded navigation property: {results: [{externalCode, id}]}
"{{pluckNested(['status'], $.expanded, 'results.0.externalCode', 'results.0.id')}}";
// Returns [{name: "status", value: "A", value_id: "1"}]

// Value only, no id
"{{pluckNested(names, $.expanded, 'results.0.externalCode')}}";
```

###### dedupe(array)

Removes duplicate entries from an array, returning a new array with unique values. Primitives are compared by type and value (so `1` and `"1"` are distinct). Objects are compared by their contents with keys sorted, so `{a:1,b:2}` and `{b:2,a:1}` are treated as duplicates.

An object whose contents cannot be serialized — one holding a circular reference or a BigInt — is compared by identity instead: the same object listed twice collapses to one, but two structurally identical ones are both kept, because there is no content to compare them by.

- `array` (array): The array to deduplicate
- Returns: array with duplicates removed, preserving original order

```js
// Dedupe numbers
"{{dedupe([1, 2, 2, 3, 1])}}"; // Returns [1, 2, 3]

// Dedupe strings
"{{dedupe(['a', 'b', 'a', 'c'])}}"; // Returns ["a", "b", "c"]

// Dedupe objects by value (key order doesn't matter)
"{{dedupe([{id: 1}, {id: 2}, {id: 1}])}}"; // Returns [{id: 1}, {id: 2}]
"{{dedupe([{a: 1, b: 2}, {b: 2, a: 1}])}}"; // Returns [{a: 1, b: 2}]

// Dedupe from context variable
"{{dedupe($.tags)}}";
```

###### join(array, separator?)

Joins array elements into a string with a specified separator. Null and undefined values in the array are filtered out.

- `array` (array): The array to join
- `separator` (string, optional): The string to use between elements (default: ',')
- Returns: joined string, or empty string for invalid/non-array input

```js
// Join with default comma separator
"{{join(['a', 'b', 'c'])}}"; // Returns "a,b,c"

// Join with custom separator
"{{join(['a', 'b', 'c'], ' - ')}}"; // Returns "a - b - c"
"{{join([1, 2, 3], '|')}}"; // Returns "1|2|3"

// Join with empty string (concatenate)
"{{join(['a', 'b', 'c'], '')}}"; // Returns "abc"

// Null/undefined values are filtered out
"{{join(['a', null, 'b', undefined, 'c'])}}"; // Returns "a,b,c"

// Join from context variable
"{{join($.tags, ', ')}}";
"{{join(data.items, ' | ')}}";
```

###### slice(array, start?, end?)

Returns a shallow copy of a portion of an array, selected by position. Mirrors `Array.prototype.slice` for numeric bounds: fractions truncate and negative bounds count back from the end. A bound that is not a finite number returns an empty array rather than being coerced. Omitting `end` returns everything from `start` onwards.

Useful for paging a large array across several calls: resolve the full list once, then hand each call a different window of it.

- `array` (array): The array to take a portion of
- `start` (number, optional): Zero-based index to begin at (default: 0). Negative counts from the end.
- `end` (number, optional): Zero-based index to stop before (default: end of array). Negative counts from the end.
- Returns: a new array with the selected elements, or an empty array for a non-array input

```js
// Window between two bounds
"{{slice([1, 2, 3, 4, 5], 1, 3)}}"; // Returns [2, 3]

// Everything from an index onwards
"{{slice([1, 2, 3, 4, 5], 2)}}"; // Returns [3, 4, 5]

// Negative bounds count from the end
"{{slice([1, 2, 3, 4, 5], -2)}}"; // Returns [4, 5]

// Bounds computed from context — the paging case
// offset and pageSize must be numbers: JEXL's + concatenates strings, and a string bound returns []
"{{slice($.ids, $.offset, $.offset + $.pageSize)}}";

// A window past the end returns empty, which makes paging terminate naturally
"{{slice([1, 2, 3], 10, 20)}}"; // Returns []

// End before start, or a non-array input, also return empty
"{{slice([1, 2, 3], 2, 1)}}"; // Returns []
"{{slice('abc', 0, 2)}}"; // Returns []

// Fractions truncate; a bound that is not a finite number returns empty rather than being coerced
"{{slice([1, 2, 3, 4, 5], 0, 2.5)}}"; // Returns [1, 2]
"{{slice([1, 2, 3, 4, 5], '1', '3')}}"; // Returns []
"{{slice([1, 2, 3], 1, 'x')}}"; // Returns []
```

##### Object Functions

###### present(object)

Checks if an object is present (not null or undefined).

- `object` (object): The object to check
- Returns: boolean

```js
// Check if an object is present
"{{present({})}}"; // Returns true
"{{present(null)}}"; // Returns false
"{{present(undefined)}}"; // Returns false
"{{present('string')}}"; // Returns true
"{{present(0)}}"; // Returns true
"{{present([])}}"; // Returns true
```

###### missing(object)

Checks if an object is missing (null or undefined).

- `object` (object): The object to check
- Returns: boolean

```js
// Check if an object is not present
"{{missing({})}}"; // Returns false
"{{missing(null)}}"; // Returns true
"{{missing(undefined)}}"; // Returns true
"{{missing('string')}}"; // Returns false
"{{missing(0)}}"; // Returns false
"{{missing([])}}"; // Returns false
```

###### keys(object)

Returns the keys of an object as an array. If the input is not an object, null, or undefined, returns an empty array.

- `object` (object): The object to get keys from
- Returns: array of keys (string[])

```js
// Get keys from an object
"{{keys({ a: 1, b: 2 })}}"; // Returns ["a", "b"]

// Get keys from a context variable
"{{keys($.user)}}";
```

###### values(object)

Returns the values of an object as an array. If the input is not an object, null, or undefined, returns an empty array.

- `object` (object): The object to get values from
- Returns: array of values (any[])

```js
// Get values from an object
"{{values({ a: 1, b: 2 })}}"; // Returns [1, 2]

// Get values from a context variable
"{{values($.user)}}";
```

##### Math Functions

###### min(...values)

Returns the smallest value from the provided numbers.

- `values` (number | number[]): Numbers to compare (individual values or an array)
- Returns: smallest number, or null if no valid numbers provided

```js
// Multiple arguments
"{{min(3, 1, 2)}}"; // Returns 1

// Array of numbers
"{{min([5, 2, 8])}}"; // Returns 2

// Mixed positive and negative
"{{min(-10, 0, 10)}}"; // Returns -10

// From context variables
"{{min(a, b, c)}}";
"{{min($.values)}}";

// Invalid input returns null
"{{min([])}}"; // Returns null
```

##### String Functions

###### asString(value)

Converts a value to its string representation.

- `value` (any): The value to convert
- Returns: string representation of the value, or empty string for null/undefined

```js
// Convert numbers
"{{asString(42)}}"    // Returns "42"
"{{asString(3.14)}}"  // Returns "3.14"

// Convert booleans
"{{asString(true)}}"  // Returns "true"
"{{asString(false)}}" // Returns "false"

// Strings pass through unchanged
"{{asString('hello')}}" // Returns "hello"

// Arrays and objects are JSON-stringified
"{{asString([1, 2, 3])}}"  // Returns "[1,2,3]"
"{{asString({a: 1})}}"     // Returns '{"a":1}'

// Null/undefined returns empty string
"{{asString(null)}}" // Returns ""

// From context variable
"{{asString($.userId)}}"
```

###### capitalize(value, mode?)

Capitalizes characters in a string.

- `value` (string): The string to capitalize
- `mode` (string, optional): 'first' to capitalize first character only, 'each' to capitalize each word (default: 'first')
- Returns: capitalized string, or empty string for invalid input

```js
// Capitalize first character (default)
"{{capitalize('hello')}}"; // Returns "Hello"
"{{capitalize('hello world')}}"; // Returns "Hello world"

// Capitalize each word
"{{capitalize('hello world', 'each')}}"; // Returns "Hello World"
"{{capitalize('the great gatsby', 'each')}}"; // Returns "The Great Gatsby"

// Capitalize from context variable
"{{capitalize($.name)}}";
"{{capitalize($.title, 'each')}}";
```

###### decodeBase64(encodedValue)

Decodes a Base64 encoded string and returns the decoded result.

- `encodedValue` (string): The Base64 encoded string to decode
- Returns: decoded string, or empty string if input is invalid

```js
// Decode a base64 string directly
"{{decodeBase64("SGVsbG8gV29ybGQ")}}" // Returns "Hello World"

// Decode from context variable
"{{decodeBase64($.encodedValue)}}" // Decodes the encodedValue from context

// Handles invalid cases gracefully
"{{decodeBase64(null)}}" // Returns ""
"{{decodeBase64("!")}}" // Returns ""
"{{decodeBase64(123)}}" // Returns ""
```

###### encodeBase64(value)

Encodes a string to Base64 and returns the encoded result.

- `value` (string): The string to encode
- Returns: encoded string, or empty string if input is invalid

```js
// Encode a string directly
"{{encodeBase64("Hello World")}}" // Returns "SGVsbG8gV29ybGQ"

// Encode from context variable
"{{encodeBase64($.value)}}" // Encodes the value from context

// Handles invalid cases gracefully
"{{encodeBase64(null)}}" // Returns ""
"{{encodeBase64(123)}}" // Returns ""
"{{encodeBase64("")}}" // Returns ""
```

###### decodeUri(value)

Decodes a URI-encoded string, converting percent-encoded characters back to their original form. Useful for handling pre-encoded values (like pagination cursors) that would otherwise be double-encoded when passed as query parameters.

- `value` (string): The URI-encoded string to decode
- Returns: decoded string, or empty string if input is invalid

```js
// Decode basic percent-encoded characters
"{{decodeUri('hello%20world')}}" // Returns "hello world"
"{{decodeUri('cursor%3Dabc%26page%3D2')}}" // Returns "cursor=abc&page=2"

// Decode Base64 padding characters (common in cursors)
"{{decodeUri('eyJpZCI6MTIzfQ%3D%3D')}}" // Returns "eyJpZCI6MTIzfQ=="

// Decode special characters
"{{decodeUri('%2B%2F%3D')}}" // Returns "+/="

// Already decoded strings are returned unchanged
"{{decodeUri('already decoded')}}" // Returns "already decoded"

// Handles invalid cases gracefully
"{{decodeUri(null)}}" // Returns ""
"{{decodeUri(123)}}" // Returns ""

// Malformed encoding returns original string
"{{decodeUri('invalid%ZZ')}}" // Returns "invalid%ZZ"

// Decode from context variable (Confluence pagination example)
"{{decodeUri($.response.cursor)}}"
```

###### encodeUri(value, variant?)

Encodes a string for use in a URI, percent-encoding the characters that would otherwise change the meaning of a URL, such as `=`, `&`, `?`, `/`, `:` and space. The inverse of `decodeUri`.

> **Not for `query` map values** — the transport percent-encodes those already, so pre-encoding double-encodes them (`firstName:asc` arrives as `firstName%253Aasc`). Use it where the transport does not encode for you: strings hashed by a signing scheme, values interpolated into a `url` string, and header values.

- `value` (string): The string to URI-encode
- `variant` (string, optional): Which characters stay unencoded — `'component'` (default) or `'rfc3986'`
- Returns: percent-encoded string, or empty string if the value or variant is invalid

| Variant | Left unencoded | Use for |
| --- | --- | --- |
| `component` (default) | `A-Za-z0-9-._~` and `!'()*` | General query strings. Matches `encodeURIComponent` and the "urlencode" behaviour of most HTTP clients and provider SDKs |
| `rfc3986` | `A-Za-z0-9-._~` only | Canonical query strings for request signing. Matches the `rfc3986Encode` helper used by the AWS SigV4 and HMAC signing strategies in `@stackone/transport` |

Use `rfc3986` whenever the encoded value is hashed into a signature — a signer expecting strict encoding will not match `component` output on any of `!'()*`.

```js
// Encode reserved characters
"{{encodeUri('hello world')}}" // Returns "hello%20world"
"{{encodeUri('cursor=abc&page=2')}}" // Returns "cursor%3Dabc%26page%3D2"

// Encode base64 padding characters (common in cursors)
"{{encodeUri('eyJpZCI6MTIzfQ==')}}" // Returns "eyJpZCI6MTIzfQ%3D%3D"

// Unreserved characters are left unchanged
"{{encodeUri('a-b_c.d~e')}}" // Returns "a-b_c.d~e"

// Sub-delimiters are left unchanged by the default variant
"{{encodeUri(\"a!b'c(d)e*f\")}}" // Returns "a!b'c(d)e*f"

// The rfc3986 variant encodes them, for signed canonical query strings
"{{encodeUri(\"a!b'c(d)e*f\", 'rfc3986')}}" // Returns "a%21b%27c%28d%29e%2Af"

// Handles invalid cases gracefully
"{{encodeUri(null)}}" // Returns ""
"{{encodeUri(123)}}" // Returns ""

// Build a canonical query string for request signing — always the rfc3986 variant
"{{'sortBy=' + encodeUri($.sortBy, 'rfc3986') + '&filter=' + encodeUri($.filter, 'rfc3986')}}" // Returns "sortBy=firstName%3Aasc&filter=name%20eq%20%28John%29"

// Non-string values return "", so wrap numbers in asString to avoid dropping them
"{{encodeUri(10)}}" // Returns ""
"{{encodeUri(asString(10))}}" // Returns "10"

// An unrecognised variant returns "" rather than silently using the weaker encoding
"{{encodeUri('firstName:asc', 'RFC3986')}}" // Returns ""
```

###### truncate(value, maxLength, suffix?)

Truncates a string to a specified maximum length, optionally appending a suffix.

- `value` (string): The string to truncate
- `maxLength` (number): Maximum length of the result (including suffix)
- `suffix` (string, optional): Suffix to append when truncating, defaults to "..."
- Returns: truncated string, or original string if shorter than maxLength

```js
// Truncate with default suffix
"{{truncate('Hello World', 8)}}"; // Returns "Hello..."

// Truncate with custom suffix
"{{truncate('Hello World', 8, '…')}}"; // Returns "Hello W…"

// Truncate with no suffix
"{{truncate('Hello World', 5, '')}}"; // Returns "Hello"

// No truncation needed
"{{truncate('Hi', 10)}}"; // Returns "Hi"

// Truncate from context variable
"{{truncate($.description, 100)}}";
```

###### padStart(value, targetLength, padString?)

Pads the start of a string with another string until it reaches the target length.

- `value` (string | number): The value to pad (numbers are converted to strings)
- `targetLength` (number): The target length of the resulting string
- `padString` (string, optional): The string to pad with, defaults to space " "
- Returns: padded string, or original if already at or beyond target length

```js
// Pad with zeros (common for formatting numbers)
"{{padStart('5', 3, '0')}}"; // Returns "005"
"{{padStart(5, 3, '0')}}"; // Returns "005" (numbers work too)

// Pad with default space
"{{padStart('hello', 10)}}"; // Returns "     hello"

// Pad with custom character
"{{padStart('abc', 6, '*')}}"; // Returns "***abc"

// No padding needed if already long enough
"{{padStart('hello', 3)}}"; // Returns "hello"

// Pad from context variable
"{{padStart($.id, 8, '0')}}";
```

###### replace(value, search, replacement, replaceAll?)

Replaces occurrences of a search string with a replacement string.

- `value` (string): The string to perform replacement on
- `search` (string): The substring to search for
- `replacement` (string): The string to replace matches with
- `replaceAll` (boolean, optional): If true, replaces all occurrences; otherwise replaces only the first (default: false)
- Returns: string with replacements made, or original string if search not found

```js
// Replace first occurrence (default)
"{{replace('hello world', 'world', 'there')}}"; // Returns "hello there"
"{{replace('hello hello', 'hello', 'hi')}}"; // Returns "hi hello"

// Replace all occurrences
"{{replace('hello hello', 'hello', 'hi', true)}}"; // Returns "hi hi"
"{{replace('foo-bar-baz', '-', '_', true)}}"; // Returns "foo_bar_baz"

// Remove characters by replacing with empty string
"{{replace('a-b-c', '-', '', true)}}"; // Returns "abc"

// Replace from context variable
"{{replace($.text, ' ', '-', true)}}";
```

###### split(value, separator?)

Splits a string into an array of substrings using a specified separator.

- `value` (string): The string to split
- `separator` (string, optional): The string used to separate the value (default: ',')
- Returns: array of substrings, or empty array for non-string input

```js
// Split with default comma separator
"{{split('a,b,c')}}"; // Returns ["a", "b", "c"]

// Split with custom separator
"{{split('a-b-c', '-')}}"; // Returns ["a", "b", "c"]
"{{split('hello world', ' ')}}"; // Returns ["hello", "world"]
"{{split('a|b|c', '|')}}"; // Returns ["a", "b", "c"]

// Split with multi-character separator
"{{split('a AND b AND c', ' AND ')}}"; // Returns ["a", "b", "c"]

// Split into characters with empty separator
"{{split('hello', '')}}"; // Returns ["h", "e", "l", "l", "o"]

// Split from context variable
"{{split($.tags, ',')}}";
"{{split(data.csv, ',')}}";
```

###### regexMatch(value, pattern, groupIndex?)

Extracts a value from a string using a regular expression pattern. Returns the specified capture group, or null if no match is found.

- `value` (string): The string to search in
- `pattern` (string): The regex pattern as a string (without delimiters)
- `groupIndex` (number, optional): Capture group index to return (default: 1 for first capture group, 0 for full match)
- Returns: matched string from the specified group, or null if no match or group doesn't exist

```js
// Extract parameter from URL or header
"{{regexMatch('<https://api.com?after=abc123&limit=2>; rel=\"next\"', 'after=([^&>]+)', 1)}}"; // Returns "abc123"

// Extract full match (group 0)
"{{regexMatch('Hello World', 'World', 0)}}"; // Returns "World"

// Extract with capture group
"{{regexMatch('user_id=12345', 'user_id=(\\d+)', 1)}}"; // Returns "12345"

// No match returns null
"{{regexMatch('Hello World', 'foo', 1)}}"; // Returns null

// Extract from context variable
"{{regexMatch($.linkHeader, 'after=([^&>]+)', 1)}}";
```

###### urlParam(url, paramName)

Extracts a query parameter value from a URL string. Useful for parsing pagination URLs where the next page token is embedded in a full URL.

- `url` (string): The URL string to parse
- `paramName` (string): The name of the query parameter to extract
- Returns: the parameter value as a string, or null if not found or inputs are invalid

```js
// Extract page number from pagination URL
"{{urlParam('https://api.example.com/v1/items?page=2&limit=10', 'page')}}"; // Returns "2"

// Extract cursor from next page URL
"{{urlParam('https://api.example.com/v1/items?cursor=eyJpZCI6MTIzfQ==&limit=50', 'cursor')}}"; // Returns "eyJpZCI6MTIzfQ=="

// Extract offset parameter
"{{urlParam('https://api.example.com/v1/items?offset=100&limit=25', 'offset')}}"; // Returns "100"

// Returns null when parameter doesn't exist
"{{urlParam('https://api.example.com?page=1', 'missing')}}"; // Returns null

// From context variable (common pagination use case)
"{{urlParam($.pagination.next_page, 'page')}}";

// Use in conditional for pagination
"{{urlParam(nextUrl, 'page') != null ? urlParam(nextUrl, 'page') : null}}";
```

##### Crypto Functions

###### sha256(value, encoding?)

Computes the SHA-256 hash of a string value.

- `value` (string): The string to hash
- `encoding` (string, optional): Output encoding — `'hex'` (default) or `'base64'`
- Returns: hashed string, or empty string for invalid input

```js
"{{sha256('hello')}}"           // Returns "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
"{{sha256('hello', 'base64')}}" // Returns "LPJNul+wow4m6DsqxbninhsWHlwfp0JecwQzYpOLmCQ="

// Hash from context variable
"{{sha256($.body)}}"
```

###### hmacSha256(value, key, encoding?)

Computes an HMAC-SHA256 of a value using a secret key. Useful for webhook signature verification and API request signing.

- `value` (string): The string to sign
- `key` (string): The secret key
- `encoding` (string, optional): Output encoding — `'hex'` (default) or `'base64'`
- Returns: HMAC digest, or empty string for invalid input

```js
"{{hmacSha256('hello', 'secret')}}"           // Returns "88aab3ede8d3adf94d26ab90d3bafd4a2083070c3bcce9c014ee04a443847c0b"
"{{hmacSha256('hello', 'secret', 'base64')}}" // Returns base64-encoded HMAC

// Sign from context variables
"{{hmacSha256($.body, $.signingSecret)}}"
```

###### md5(value, encoding?)

Computes the MD5 hash of a string value.

- `value` (string): The string to hash
- `encoding` (string, optional): Output encoding — `'hex'` (default) or `'base64'`
- Returns: MD5 hash, or empty string for invalid input

```js
"{{md5('hello')}}"           // Returns "5d41402abc4b2a76b9719d911017c592"
"{{md5('hello', 'base64')}}" // Returns "XUFAKrxLKna5cZ2REBfFkg=="

// Hash from context variable
"{{md5($.body, 'base64')}}"
```

##### XML Functions

###### wrapXmlArray(value, wrapperElement?, typeAttribute?)

Wraps an array in an object shaped for XML serialization with fast-xml-parser, so each item becomes a repeated child element (e.g. Braintree-style `<ids type="array"><item>a</item></ids>`).

- `value` (array): The array to wrap
- `wrapperElement` (string, optional): Element name for each item (default: `'item'`); falls back to `'item'` when not a valid XML element name
- `typeAttribute` (string, optional): Non-empty string value for the `type` attribute (default: `'array'`); any other value (`null`, `false`, `''`) omits the attribute
- Returns: wrapped object for XML serialization, or `undefined` for non-array input

Use `{{...}}` (JEXL) — NOT `${...}` string interpolation, which stringifies the returned object to `"[object Object]"`.

```js
// Default (Braintree style)
"{{wrapXmlArray(args.ids)}}"; // Returns { '@_type': 'array', item: [...] }
// XML: <parent type="array"><item>a</item><item>b</item></parent>

// Custom wrapper element
"{{wrapXmlArray(args.ids, 'entry')}}"; // Returns { '@_type': 'array', entry: [...] }

// Omit the type attribute (use '' or false — JEXL null resolves to undefined, so the default applies)
"{{wrapXmlArray(args.ids, 'item', '')}}"; // Returns { item: [...] }

// Non-array input
"{{wrapXmlArray(args.missing)}}"; // Returns undefined
```

For more information on the JEXL syntax, refer to the [JEXL Syntax documentation](https://commons.apache.org/proper/commons-jexl/reference/syntax.html).

### String Interpolation

To simplify strings usage, a more straightforward syntax is provided for string interpolation of variables using the `${var}` syntax.

Examples:

```js
// Given the context: { name: "John", age: 30 }
"Hello ${name}"; // Returns "Hello John"
"User is ${age}"; // Returns "User is 30"
// You can also use JEXL inside string syntax
"Status: ${age > 18 ? 'Adult' : 'Minor'}"; // Returns "Status: Adult"
"Age in 5 years: ${age + 5}"; // Returns "Age in 5 years: 35"
```

Note: If the expression is a string without any of the patterns described above, it will be returned as is.

```js
// Given the context: { name: "John", age: 30 }
"Hello world"; // Returns "Hello world"
```
