feat: Expression extension framework (#4372)

*  Introduce a framework for expression extension

* 💡 Add some inline comments

*  Introduce hash alias for encrypt

*  Introduce a manual granular level approach to shadowing/overrideing extensions

* 🔥 Cleanup comments

*  Introduce a basic method of extension for native functions

*  Add length to StringExtension

*  Add number type to extension return types

*  Temporarily introduce DateTime with extension

*  Cleanup comments

*  Organize imports

* ♻️ Fix up some typings

*  Fix typings

* ♻️ Remove unnecessary resolve of expression

*  Extensions Improvement

* ♻️ Refactor EXPRESSION_EXTENSION_METHODS

* ♻️ Refactor EXPRESSION_EXTENSION_METHODS

* ♻️ Update extraArgs types

* ♻️ Fix tests

* ♻️ Fix bind type issue

* ♻️ Fixing duration type issue

* ♻️ Refactor to allow overrides on native methods

* ♻️ Temporarily remove Date Extensions to pass tests

* feat(dt-functions): introduce date expression extensions (#4045)

* 🎉 Add Date Extensions into the mix

*  Introduce additional date extension methods

*  Add Date Expression Extension tests

* 🔧 Add ability to debug tests

* ♻️ Refactor extension for native types

* 🔥 Move sayHi method to String Extension class

* ♻️ Update scope when binding member methods

*  Add String Extension tests

* feat(dt-functions): introduce array expression extensions (#4044)

*  Introduce Array Extensions

*  Add Array Expression tests

* feat(dt-functions): introduce number expression extensions (#4046)

* 🎉 Introduce Number Extensions

*  Support more shared extensions

*  Improve handling of name collision

*  Update tests

* Fixed up tests

* 🔥 Remove remove markdown

* :recylce: Replace remove-markdown dependencies with implementation

* ♻️ Replace remove-markdown dependencies with implementation

*  Update tests

* ♻️ Fix scoping and cleanup

* ♻️ Update comments and errors

* ♻️ Fix linting errors

*  Remove unused dependencies

* fix: expression extension not working with multiple extensions

* refactor: change extension transform to be more efficient

* test: update most test to work with new extend function

* fix: update and fix type error in config

* refactor: replace babel with recast

* feat: add hashing functions to string extension

* fix: removed export

* test: add extension parser and transform tests

* fix: vite tests breaking

* refactor: remove commented out code

* fix: parse dates passed from $json in extend function

* refactor: review feedback changes for date extensions

* refactor: review feedback changes for number extensions

* fix: date extension beginningOf test

* fix: broken build from merge

* fix: another merge issue

* refactor: address review feedback (remove ignores)

* feat: new extension functions and tests

* feat: non-dot notation functions

* test: most of the other tests

* fix: toSentenceCase for node versions below 16.6

* feat: add $if and $not expression extensions

* Fix test to work on every timezone

* lint: fix remaining lint issues

Co-authored-by: Csaba Tuncsik <csaba@n8n.io>
Co-authored-by: Omar Ajoue <krynble@gmail.com>
This commit is contained in:
Valya
2023-01-10 13:06:12 +00:00
committed by GitHub
parent 871a1d7dad
commit 3d05acf313
25 changed files with 2529 additions and 5 deletions

View File

@@ -0,0 +1,392 @@
import { ExpressionError, ExpressionExtensionError } from '../ExpressionError';
import type { ExtensionMap } from './Extensions';
import { compact as oCompact, merge as oMerge } from './ObjectExtensions';
function deepCompare(left: unknown, right: unknown): boolean {
if (left === right) {
return true;
}
// Check to see if they're the basic type
if (typeof left !== typeof right) {
return false;
}
if (typeof left === 'number' && isNaN(left) && isNaN(right as number)) {
return true;
}
// Quickly check how many properties each has to avoid checking obviously mismatching
// objects
if (Object.keys(left as object).length !== Object.keys(right as object).length) {
return false;
}
// Quickly check if they're arrays
if (Array.isArray(left) !== Array.isArray(right)) {
return false;
}
// Check if arrays are equal, ordering is important
if (Array.isArray(left)) {
if (left.length !== (right as unknown[]).length) {
return false;
}
return left.every((v, i) => deepCompare(v, (right as object[])[i]));
}
// Check right first quickly. This is to see if we have mismatched properties.
// We'll check the left more indepth later to cover all our bases.
for (const key in right as object) {
if ((left as object).hasOwnProperty(key) !== (right as object).hasOwnProperty(key)) {
return false;
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any
} else if (typeof (left as any)[key] !== typeof (right as any)[key]) {
return false;
}
}
// Check left more in depth
for (const key in left as object) {
if ((left as object).hasOwnProperty(key) !== (right as object).hasOwnProperty(key)) {
return false;
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any
} else if (typeof (left as any)[key] !== typeof (right as any)[key]) {
return false;
}
if (
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any
typeof (left as any)[key] === 'object'
) {
if (
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any
(left as any)[key] !== (right as any)[key] &&
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any
!deepCompare((left as any)[key], (right as any)[key])
) {
return false;
}
} else {
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any
if ((left as any)[key] !== (right as any)[key]) {
return false;
}
}
}
return true;
}
function first(value: unknown[]): unknown {
return value[0];
}
function isBlank(value: unknown[]): boolean {
return value.length === 0;
}
function isPresent(value: unknown[]): boolean {
return value.length > 0;
}
function last(value: unknown[]): unknown {
return value[value.length - 1];
}
function length(value: unknown[]): number {
return Array.isArray(value) ? value.length : 0;
}
function pluck(value: unknown[], extraArgs: unknown[]): unknown[] {
if (!Array.isArray(extraArgs)) {
throw new ExpressionError('arguments must be passed to pluck');
}
const fieldsToPluck = extraArgs;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (value as any[]).map((element: object) => {
const entries = Object.entries(element);
return entries.reduce((p, c) => {
const [key, val] = c as [string, Date | string | number];
if (fieldsToPluck.includes(key)) {
Object.assign(p, { [key]: val });
}
return p;
}, {});
}) as unknown[];
}
function random(value: unknown[]): unknown {
const len = value === undefined ? 0 : value.length;
return len ? value[Math.floor(Math.random() * len)] : undefined;
}
function unique(value: unknown[], extraArgs: string[]): unknown[] {
if (extraArgs.length) {
return value.reduce<unknown[]>((l, v) => {
if (typeof v === 'object' && v !== null && extraArgs.every((i) => i in v)) {
const alreadySeen = l.find((i) =>
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any
extraArgs.every((j) => deepCompare((i as any)[j], (v as any)[j])),
);
if (!alreadySeen) {
l.push(v);
}
}
return l;
}, []);
}
return value.reduce<unknown[]>((l, v) => {
if (l.findIndex((i) => deepCompare(i, v)) === -1) {
l.push(v);
}
return l;
}, []);
}
function sum(value: unknown[]): number {
return value.reduce((p: number, c: unknown) => {
if (typeof c === 'string') {
return p + parseFloat(c);
}
if (typeof c !== 'number') {
return NaN;
}
return p + c;
}, 0);
}
function min(value: unknown[]): number {
return Math.min(
...value.map((v) => {
if (typeof v === 'string') {
return parseFloat(v);
}
if (typeof v !== 'number') {
return NaN;
}
return v;
}),
);
}
function max(value: unknown[]): number {
return Math.max(
...value.map((v) => {
if (typeof v === 'string') {
return parseFloat(v);
}
if (typeof v !== 'number') {
return NaN;
}
return v;
}),
);
}
export function average(value: unknown[]) {
// This would usually be NaN but I don't think users
// will expect that
if (value.length === 0) {
return 0;
}
return sum(value) / value.length;
}
function compact(value: unknown[]): unknown[] {
return value
.filter((v) => v !== null && v !== undefined)
.map((v) => {
if (typeof v === 'object' && v !== null) {
return oCompact(v);
}
return v;
});
}
function smartJoin(value: unknown[], extraArgs: string[]): object {
const [keyField, valueField] = extraArgs;
if (!keyField || !valueField || typeof keyField !== 'string' || typeof valueField !== 'string') {
throw new ExpressionExtensionError(
'smartJoin requires 2 arguments: keyField and nameField. e.g. .smartJoin("name", "value")',
);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-return
return value.reduce<any>((o, v) => {
if (typeof v === 'object' && v !== null && keyField in v && valueField in v) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any
o[(v as any)[keyField]] = (v as any)[valueField];
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return o;
}, {});
}
function chunk(value: unknown[], extraArgs: number[]) {
const [chunkSize] = extraArgs;
if (typeof chunkSize !== 'number') {
throw new ExpressionExtensionError('chunk requires 1 parameter: chunkSize. e.g. .chunk(5)');
}
const chunks: unknown[][] = [];
for (let i = 0; i < value.length; i += chunkSize) {
// I have no clue why eslint thinks 2 numbers could be anything but that but here we are
// eslint-disable-next-line @typescript-eslint/restrict-plus-operands
chunks.push(value.slice(i, i + chunkSize));
}
return chunks;
}
function filter(value: unknown[], extraArgs: unknown[]): unknown[] {
const [field, term] = extraArgs as [string | (() => void), unknown | string];
if (typeof field !== 'string' && typeof field !== 'function') {
throw new ExpressionExtensionError(
'filter requires 1 or 2 arguments: (field and term), (term and [optional keepOrRemove "keep" or "remove" default "keep"] (for string arrays)), or function. e.g. .filter("type", "home") or .filter((i) => i.type === "home") or .filter("home", [optional keepOrRemove]) (for string arrays)',
);
}
if (value.every((i) => typeof i === 'string') && typeof field === 'string') {
return (value as string[]).filter((i) =>
term === 'remove' ? !i.includes(field) : i.includes(field),
);
} else if (typeof field === 'string') {
return value.filter(
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any
(v) => typeof v === 'object' && v !== null && field in v && (v as any)[field] === term,
);
}
return value.filter(field);
}
function renameKeys(value: unknown[], extraArgs: string[]): unknown[] {
if (extraArgs.length === 0 || extraArgs.length % 2 !== 0) {
throw new ExpressionExtensionError(
'renameKeys requires an even amount of arguments: from1, to1 [, from2, to2, ...]. e.g. .renameKeys("name", "title")',
);
}
return value.map((v) => {
if (typeof v !== 'object' || v === null) {
return v;
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-explicit-any
const newObj = { ...(v as any) };
const chunkedArgs = chunk(extraArgs, [2]) as string[][];
chunkedArgs.forEach(([from, to]) => {
if (from in newObj) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
newObj[to] = newObj[from];
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
delete newObj[from];
}
});
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return newObj;
});
}
function merge(value: unknown[], extraArgs: unknown[][]): unknown[] {
const [others] = extraArgs;
if (!Array.isArray(others)) {
throw new ExpressionExtensionError(
'merge requires 1 argument that is an array. e.g. .merge([{ id: 1, otherValue: 3 }])',
);
}
const listLength = value.length > others.length ? value.length : others.length;
const newList = new Array(listLength);
for (let i = 0; i < listLength; i++) {
if (value[i] !== undefined) {
if (typeof value[i] === 'object' && typeof others[i] === 'object') {
newList[i] = oMerge(value[i] as object, [others[i]]);
} else {
newList[i] = value[i];
}
} else {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
newList[i] = others[i];
}
}
return newList;
}
function union(value: unknown[], extraArgs: unknown[][]): unknown[] {
const [others] = extraArgs;
if (!Array.isArray(others)) {
throw new ExpressionExtensionError(
'union requires 1 argument that is an array. e.g. .union([1, 2, 3, 4])',
);
}
const newArr: unknown[] = Array.from(value);
for (const v of others) {
if (newArr.findIndex((w) => deepCompare(w, v)) === -1) {
newArr.push(v);
}
}
return unique(newArr, []);
}
function difference(value: unknown[], extraArgs: unknown[][]): unknown[] {
const [others] = extraArgs;
if (!Array.isArray(others)) {
throw new ExpressionExtensionError(
'difference requires 1 argument that is an array. e.g. .difference([1, 2, 3, 4])',
);
}
const newArr: unknown[] = [];
for (const v of value) {
if (others.findIndex((w) => deepCompare(w, v)) === -1) {
newArr.push(v);
}
}
return unique(newArr, []);
}
function intersection(value: unknown[], extraArgs: unknown[][]): unknown[] {
const [others] = extraArgs;
if (!Array.isArray(others)) {
throw new ExpressionExtensionError(
'difference requires 1 argument that is an array. e.g. .difference([1, 2, 3, 4])',
);
}
const newArr: unknown[] = [];
for (const v of value) {
if (others.findIndex((w) => deepCompare(w, v)) !== -1) {
newArr.push(v);
}
}
for (const v of others) {
if (value.findIndex((w) => deepCompare(w, v)) !== -1) {
newArr.push(v);
}
}
return unique(newArr, []);
}
export const arrayExtensions: ExtensionMap = {
typeName: 'Array',
functions: {
count: length,
duplicates: unique,
filter,
first,
last,
length,
pluck,
unique,
random,
randomItem: random,
remove: unique,
size: length,
sum,
min,
max,
average,
isPresent,
isBlank,
compact,
smartJoin,
chunk,
renameKeys,
merge,
union,
difference,
intersection,
},
};

View File

@@ -0,0 +1,268 @@
/* eslint-disable @typescript-eslint/unbound-method */
/* eslint-disable @typescript-eslint/explicit-member-accessibility */
import {
DateTime,
DateTimeFormatOptions,
DateTimeUnit,
Duration,
DurationObjectUnits,
LocaleOptions,
} from 'luxon';
import type { ExtensionMap } from './Extensions';
type DurationUnit =
| 'milliseconds'
| 'seconds'
| 'minutes'
| 'hours'
| 'days'
| 'weeks'
| 'months'
| 'quarter'
| 'years';
type DatePart =
| 'day'
| 'month'
| 'year'
| 'hour'
| 'minute'
| 'second'
| 'millisecond'
| 'weekNumber'
| 'yearDayNumber'
| 'weekday';
const DURATION_MAP: Record<string, DurationUnit> = {
day: 'days',
month: 'months',
year: 'years',
week: 'weeks',
hour: 'hours',
minute: 'minutes',
second: 'seconds',
millisecond: 'milliseconds',
ms: 'milliseconds',
sec: 'seconds',
secs: 'seconds',
hr: 'hours',
hrs: 'hours',
min: 'minutes',
mins: 'minutes',
};
const DATETIMEUNIT_MAP: Record<string, DateTimeUnit> = {
days: 'day',
months: 'month',
years: 'year',
hours: 'hour',
minutes: 'minute',
seconds: 'second',
milliseconds: 'millisecond',
hrs: 'hour',
hr: 'hour',
mins: 'minute',
min: 'minute',
secs: 'second',
sec: 'second',
ms: 'millisecond',
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function isDateTime(date: any): date is DateTime {
if (date) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
return DateTime.isDateTime(date);
}
return false;
}
function generateDurationObject(durationValue: number, unit: DurationUnit) {
const convertedUnit = DURATION_MAP[unit] || unit;
return { [`${convertedUnit}`]: durationValue } as DurationObjectUnits;
}
function beginningOf(date: Date | DateTime, extraArgs: DurationUnit[]): Date {
const [unit = 'week'] = extraArgs;
if (isDateTime(date)) {
return date.startOf(DATETIMEUNIT_MAP[unit] || unit).toJSDate();
}
let datetime = DateTime.fromJSDate(date);
if (date.getTimezoneOffset() === 0) {
datetime = datetime.setZone('UTC');
}
return datetime.startOf(DATETIMEUNIT_MAP[unit] || unit).toJSDate();
}
function endOfMonth(date: Date | DateTime): Date {
if (isDateTime(date)) {
return date.endOf('month').toJSDate();
}
return DateTime.fromJSDate(date).endOf('month').toJSDate();
}
function extract(inputDate: Date | DateTime, extraArgs: DatePart[]): number | Date {
const [part] = extraArgs;
let date = inputDate;
if (isDateTime(date)) {
date = date.toJSDate();
}
if (part === 'yearDayNumber') {
const firstDayOfTheYear = new Date(date.getFullYear(), 0, 0);
const diff =
date.getTime() -
firstDayOfTheYear.getTime() +
(firstDayOfTheYear.getTimezoneOffset() - date.getTimezoneOffset()) * 60 * 1000;
return Math.floor(diff / (1000 * 60 * 60 * 24));
}
return DateTime.fromJSDate(date).get((DATETIMEUNIT_MAP[part] as keyof DateTime) || part);
}
function format(date: Date | DateTime, extraArgs: unknown[]): string {
const [dateFormat, localeOpts = {}] = extraArgs as [string, LocaleOptions];
if (isDateTime(date)) {
return date.toFormat(dateFormat, { ...localeOpts });
}
return DateTime.fromJSDate(date).toFormat(dateFormat, { ...localeOpts });
}
function isBetween(date: Date | DateTime, extraArgs: unknown[]): boolean {
const [first, second] = extraArgs as string[];
const firstDate = new Date(first);
const secondDate = new Date(second);
if (firstDate > secondDate) {
return secondDate < date && date < firstDate;
}
return secondDate > date && date > firstDate;
}
function isDst(date: Date): boolean {
return DateTime.fromJSDate(date).isInDST;
}
function isInLast(date: Date | DateTime, extraArgs: unknown[]): boolean {
const [durationValue = 0, unit = 'minutes'] = extraArgs as [number, DurationUnit];
const dateInThePast = DateTime.now().minus(generateDurationObject(durationValue, unit));
let thisDate = date;
if (!isDateTime(thisDate)) {
thisDate = DateTime.fromJSDate(thisDate);
}
return dateInThePast <= thisDate && thisDate <= DateTime.now();
}
function isWeekend(date: Date): boolean {
enum DAYS {
saturday = 6,
sunday = 7,
}
return [DAYS.saturday, DAYS.sunday].includes(DateTime.fromJSDate(date).weekday);
}
function minus(date: Date | DateTime, extraArgs: unknown[]): Date {
const [durationValue = 0, unit = 'minutes'] = extraArgs as [number, DurationUnit];
if (isDateTime(date)) {
return date.minus(generateDurationObject(durationValue, unit)).toJSDate();
}
return DateTime.fromJSDate(date).minus(generateDurationObject(durationValue, unit)).toJSDate();
}
function plus(date: Date | DateTime, extraArgs: unknown[]): Date {
const [durationValue = 0, unit = 'minutes'] = extraArgs as [number, DurationUnit];
if (isDateTime(date)) {
return date.plus(generateDurationObject(durationValue, unit)).toJSDate();
}
return DateTime.fromJSDate(date).plus(generateDurationObject(durationValue, unit)).toJSDate();
}
function toLocaleString(date: Date | DateTime, extraArgs: unknown[]): string {
const [locale, dateFormat = { timeStyle: 'short', dateStyle: 'short' }] = extraArgs as [
string | undefined,
DateTimeFormatOptions,
];
if (isDateTime(date)) {
return date.toLocaleString(dateFormat, { locale });
}
return DateTime.fromJSDate(date).toLocaleString(dateFormat, { locale });
}
function toTimeFromNow(date: Date): string {
let diffObj: Duration;
if (isDateTime(date)) {
diffObj = date.diffNow();
} else {
diffObj = DateTime.fromJSDate(date).diffNow();
}
const as = (unit: DurationUnit) => {
return Math.round(Math.abs(diffObj.as(unit)));
};
if (as('years')) {
return `${as('years')} years ago`;
}
if (as('months')) {
return `${as('months')} months ago`;
}
if (as('weeks')) {
return `${as('weeks')} weeks ago`;
}
if (as('days')) {
return `${as('days')} days ago`;
}
if (as('hours')) {
return `${as('hours')} hours ago`;
}
if (as('minutes')) {
return `${as('minutes')} minutes ago`;
}
if (as('seconds') && as('seconds') > 10) {
return `${as('seconds')} seconds ago`;
}
return 'just now';
}
function timeTo(date: Date | DateTime, extraArgs: unknown[]): Duration {
const [diff = new Date().toISOString(), unit = 'seconds'] = extraArgs as [string, DurationUnit];
const diffDate = new Date(diff);
if (isDateTime(date)) {
return date.diff(DateTime.fromJSDate(diffDate), DURATION_MAP[unit] || unit);
}
return DateTime.fromJSDate(date).diff(DateTime.fromJSDate(diffDate), DURATION_MAP[unit] || unit);
}
function toDate(date: Date | DateTime) {
if (isDateTime(date)) {
return date.set({ hour: 0, minute: 0, second: 0, millisecond: 0 }).toJSDate();
}
let datetime = DateTime.fromJSDate(date);
if (date.getTimezoneOffset() === 0) {
datetime = datetime.setZone('UTC');
}
return datetime.set({ hour: 0, minute: 0, second: 0, millisecond: 0 }).toJSDate();
}
export const dateExtensions: ExtensionMap = {
typeName: 'Date',
functions: {
beginningOf,
endOfMonth,
extract,
isBetween,
isDst,
isInLast,
isWeekend,
minus,
plus,
toTimeFromNow,
timeTo,
format,
toLocaleString,
toDate,
},
};

View File

@@ -0,0 +1,270 @@
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
import { DateTime } from 'luxon';
import { ExpressionExtensionError } from '../ExpressionError';
import { parse, visit, types, print } from 'recast';
import { arrayExtensions } from './ArrayExtensions';
import { dateExtensions } from './DateExtensions';
import { numberExtensions } from './NumberExtensions';
import { stringExtensions } from './StringExtensions';
import { objectExtensions } from './ObjectExtensions';
const EXPRESSION_EXTENDER = 'extend';
function isBlank(value: unknown) {
return value === null || value === undefined || !value;
}
function isPresent(value: unknown) {
return !isBlank(value);
}
const EXTENSION_OBJECTS = [
arrayExtensions,
dateExtensions,
numberExtensions,
objectExtensions,
stringExtensions,
];
// eslint-disable-next-line @typescript-eslint/ban-types
const genericExtensions: Record<string, Function> = {
isBlank,
isPresent,
};
const EXPRESSION_EXTENSION_METHODS = Array.from(
new Set([
...Object.keys(stringExtensions.functions),
...Object.keys(numberExtensions.functions),
...Object.keys(dateExtensions.functions),
...Object.keys(arrayExtensions.functions),
...Object.keys(objectExtensions.functions),
...Object.keys(genericExtensions),
'$if',
]),
);
const isExpressionExtension = (str: string) => EXPRESSION_EXTENSION_METHODS.some((m) => m === str);
export const hasExpressionExtension = (str: string): boolean =>
EXPRESSION_EXTENSION_METHODS.some((m) => str.includes(m));
export const hasNativeMethod = (method: string): boolean => {
if (hasExpressionExtension(method)) {
return false;
}
const methods = method
.replace(/[^\w\s]/gi, ' ')
.split(' ')
.filter(Boolean); // DateTime.now().toLocaleString().format() => [DateTime,now,toLocaleString,format]
return methods.every((methodName) => {
return [String.prototype, Array.prototype, Number.prototype, Date.prototype].some(
(nativeType) => {
if (methodName in nativeType) {
return true;
}
return false;
},
);
});
};
/**
* recast's types aren't great and we need to use a lot of anys
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const findParent = <T>(path: T, matcher: (path: T) => boolean): T | undefined => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
let parent = path.parentPath;
while (parent) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
if (matcher(parent)) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return parent;
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
parent = parent.parentPath;
}
return;
};
/**
* A function to inject an extender function call into the AST of an expression.
* This uses recast to do the transform.
*
* ```ts
* 'a'.method('x') // becomes
* extend('a', 'method', ['x']);
*
* 'a'.first('x').second('y') // becomes
* extend(extend('a', 'first', ['x']), 'second', ['y']));
* ```
*/
export const extendTransform = (expression: string): { code: string } | undefined => {
try {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const ast = parse(expression);
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
visit(ast, {
visitIdentifier(path) {
this.traverse(path);
if (path.node.name === '$if') {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const callPath: any = findParent(path, (p) => p.value?.type === 'CallExpression');
if (!callPath || callPath.value?.type !== 'CallExpression') {
return;
}
if (callPath.node.arguments.length < 2) {
throw new ExpressionExtensionError(
'$if requires at least 2 parameters: test, value_if_true[, and value_if_false]',
);
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const test = callPath.node.arguments[0];
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const consequent = callPath.node.arguments[1];
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const alternative =
callPath.node.arguments[2] === undefined
? types.builders.booleanLiteral(false)
: callPath.node.arguments[2];
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-argument
callPath.replace(types.builders.conditionalExpression(test, consequent, alternative));
}
},
});
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
visit(ast, {
visitIdentifier(path) {
this.traverse(path);
if (
isExpressionExtension(path.node.name) &&
// types.namedTypes.MemberExpression.check(path.parent)
path.parentPath?.value?.type === 'MemberExpression'
) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const callPath: any = findParent(path, (p) => p.value?.type === 'CallExpression');
if (!callPath || callPath.value?.type !== 'CallExpression') {
return;
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
callPath.replace(
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
types.builders.callExpression(types.builders.identifier(EXPRESSION_EXTENDER), [
path.parentPath.value.object,
types.builders.stringLiteral(path.node.name),
// eslint-disable-next-line
types.builders.arrayExpression(callPath.node.arguments),
]),
);
}
},
});
// eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-argument
return print(ast);
} catch (e) {
return;
}
};
function isDate(input: unknown): boolean {
if (typeof input !== 'string' || !input.length) {
return false;
}
if (!/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z/.test(input)) {
return false;
}
const d = new Date(input);
return d instanceof Date && !isNaN(d.valueOf()) && d.toISOString() === input;
}
/**
* Extender function injected by expression extension plugin to allow calls to extensions.
*
* ```ts
* extend(input, "functionName", [...args]);
* ```
*/
export function extend(input: unknown, functionName: string, args: unknown[]) {
// eslint-disable-next-line @typescript-eslint/ban-types
let foundFunction: Function | undefined;
if (Array.isArray(input)) {
foundFunction = arrayExtensions.functions[functionName];
} else if (isDate(input) && functionName !== 'toDate') {
// If it's a string date (from $json), convert it to a Date object,
// unless that function is `toDate`, since `toDate` does something
// very different on date objects
input = new Date(input as string);
foundFunction = dateExtensions.functions[functionName];
} else if (typeof input === 'string') {
foundFunction = stringExtensions.functions[functionName];
} else if (typeof input === 'number') {
foundFunction = numberExtensions.functions[functionName];
} else if (input && (DateTime.isDateTime(input) || input instanceof Date)) {
foundFunction = dateExtensions.functions[functionName];
} else if (input !== null && typeof input === 'object') {
foundFunction = objectExtensions.functions[functionName];
}
// Look for generic or builtin
if (!foundFunction) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const inputAny: any = input;
// This is likely a builtin we're implementing for another type
// (e.g. toLocaleString). We'll just call it
if (
inputAny &&
functionName &&
functionName in inputAny &&
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
typeof inputAny[functionName] === 'function'
) {
// I was having weird issues with eslint not finding rules on this line.
// Just disabling all eslint rules for now.
// eslint-disable-next-line
return inputAny[functionName](...args);
}
// Use a generic version if available
foundFunction = genericExtensions[functionName];
}
// No type specific or generic function found. Check to see if
// any types have a function with that name. Then throw an error
// letting the user know the available types.
if (!foundFunction) {
const haveFunction = EXTENSION_OBJECTS.filter((v) => functionName in v.functions);
if (!haveFunction.length) {
// This shouldn't really be possible but we should cover it anyway
throw new ExpressionExtensionError(`Unknown expression function: ${functionName}`);
}
if (haveFunction.length > 1) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const lastType = `"${haveFunction.pop()!.typeName}"`;
const typeNames = `${haveFunction.map((v) => `"${v.typeName}"`).join(', ')}, and ${lastType}`;
throw new ExpressionExtensionError(
`${functionName}() is only callable on types ${typeNames}`,
);
} else {
throw new ExpressionExtensionError(
`${functionName}() is only callable on type "${haveFunction[0].typeName}"`,
);
}
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return foundFunction(input, args);
}

View File

@@ -0,0 +1,99 @@
export interface ExpressionText {
type: 'text';
text: string;
}
export interface ExpressionCode {
type: 'code';
text: string;
// tmpl has different behaviours if the last expression
// doesn't close itself.
hasClosingBrackets: boolean;
}
export type ExpressionChunk = ExpressionCode | ExpressionText;
const OPEN_BRACKET = /(?<escape>\\|)(?<brackets>\{\{)/;
const CLOSE_BRACKET = /(?<escape>\\|)(?<brackets>\}\})/;
export const escapeCode = (text: string): string => {
return text.replace('\\}}', '}}');
};
export const splitExpression = (expression: string): ExpressionChunk[] => {
const chunks: ExpressionChunk[] = [];
let searchingFor: 'open' | 'close' = 'open';
let activeRegex = OPEN_BRACKET;
let buffer = '';
let index = 0;
while (index < expression.length) {
const expr = expression.slice(index);
const res = activeRegex.exec(expr);
// No more brackets. If it's a closing bracket
// this is sort of valid so we accept it but mark
// that it has no closing bracket.
if (!res?.groups) {
buffer += expr;
if (searchingFor === 'open') {
chunks.push({
type: 'text',
text: buffer,
});
} else {
chunks.push({
type: 'code',
text: escapeCode(buffer),
hasClosingBrackets: false,
});
}
break;
}
if (res.groups.escape) {
buffer += expr.slice(0, res.index + 3);
index += res.index + 3;
} else {
buffer += expr.slice(0, res.index);
if (searchingFor === 'open') {
chunks.push({
type: 'text',
text: buffer,
});
searchingFor = 'close';
activeRegex = CLOSE_BRACKET;
} else {
chunks.push({
type: 'code',
text: escapeCode(buffer),
hasClosingBrackets: true,
});
searchingFor = 'open';
activeRegex = OPEN_BRACKET;
}
index += res.index + 2;
buffer = '';
}
}
return chunks;
};
// Expressions only have closing brackets escaped
const escapeTmplExpression = (part: string) => {
return part.replace('}}', '\\}}');
};
export const joinExpression = (parts: ExpressionChunk[]): string => {
return parts
.map((chunk) => {
if (chunk.type === 'code') {
return `{{${escapeTmplExpression(chunk.text)}${chunk.hasClosingBrackets ? '}}' : ''}`;
}
return chunk.text;
})
.join('');
};

View File

@@ -0,0 +1,53 @@
import { ExpressionExtensionError } from '../ExpressionError';
import { average as aAverage } from './ArrayExtensions';
const min = Math.min;
const max = Math.max;
const numberList = (start: number, end: number): number[] => {
const size = Math.abs(start - end) + 1;
const arr = new Array<number>(size);
let curr = start;
for (let i = 0; i < size; i++) {
if (start < end) {
arr[i] = curr++;
} else {
arr[i] = curr--;
}
}
return arr;
};
const zip = (keys: unknown[], values: unknown[]): unknown => {
if (keys.length !== values.length) {
throw new ExpressionExtensionError('keys and values not of equal length');
}
return keys.reduce((p, c, i) => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any
(p as any)[c as any] = values[i];
return p;
}, {});
};
const average = (...args: number[]) => {
return aAverage(args);
};
const not = (value: unknown): boolean => {
return !value;
};
export const extendedFunctions = {
min,
max,
not,
average,
numberList,
zip,
$min: min,
$max: max,
$average: average,
$not: not,
};

View File

@@ -0,0 +1,5 @@
export interface ExtensionMap {
typeName: string;
// eslint-disable-next-line @typescript-eslint/ban-types
functions: Record<string, Function>;
}

View File

@@ -0,0 +1,73 @@
/**
* @jest-environment jsdom
*/
import { ExtensionMap } from './Extensions';
function format(value: number, extraArgs: unknown[]): string {
const [locales = 'en-US', config = {}] = extraArgs as [
string | string[],
Intl.NumberFormatOptions,
];
return new Intl.NumberFormat(locales, config).format(value);
}
function isBlank(value: number): boolean {
return typeof value !== 'number';
}
function isPresent(value: number): boolean {
return !isBlank(value);
}
function random(value: number): number {
return Math.floor(Math.random() * value);
}
function isTrue(value: number) {
return value === 1;
}
function isFalse(value: number) {
return value === 0;
}
function isEven(value: number) {
return value % 2 === 0;
}
function isOdd(value: number) {
return Math.abs(value) % 2 === 1;
}
function floor(value: number) {
return Math.floor(value);
}
function ceil(value: number) {
return Math.ceil(value);
}
function round(value: number, extraArgs: number[]) {
const [decimalPlaces = 0] = extraArgs;
return +value.toFixed(decimalPlaces);
}
export const numberExtensions: ExtensionMap = {
typeName: 'Number',
functions: {
ceil,
floor,
format,
random,
round,
isBlank,
isPresent,
isTrue,
isNotTrue: isFalse,
isFalse,
isEven,
isOdd,
},
};

View File

@@ -0,0 +1,104 @@
import { ExpressionExtensionError } from '../ExpressionError';
import type { ExtensionMap } from './Extensions';
export function merge(value: object, extraArgs: unknown[]): unknown {
const [other] = extraArgs;
if (typeof other !== 'object' || !other) {
throw new ExpressionExtensionError('argument of merge must be an object');
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const newObject: any = { ...value };
for (const [key, val] of Object.entries(other)) {
if (!(key in newObject)) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
newObject[key] = val;
}
}
return newObject;
}
function isEmpty(value: object): boolean {
return Object.keys(value).length === 0;
}
function hasField(value: object, extraArgs: string[]): boolean {
const [name] = extraArgs;
return name in value;
}
function removeField(value: object, extraArgs: string[]): object {
const [name] = extraArgs;
if (name in value) {
const newObject = { ...value };
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any
delete (newObject as any)[name];
return newObject;
}
return value;
}
function removeFieldsContaining(value: object, extraArgs: string[]): object {
const [match] = extraArgs;
if (typeof match !== 'string') {
throw new ExpressionExtensionError('argument of removeFieldsContaining must be an string');
}
const newObject = { ...value };
for (const [key, val] of Object.entries(value)) {
if (typeof val === 'string' && val.includes(match)) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any
delete (newObject as any)[key];
}
}
return newObject;
}
function keepFieldsContaining(value: object, extraArgs: string[]): object {
const [match] = extraArgs;
if (typeof match !== 'string') {
throw new ExpressionExtensionError('argument of keepFieldsContaining must be an string');
}
const newObject = { ...value };
for (const [key, val] of Object.entries(value)) {
if (typeof val === 'string' && !val.includes(match)) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any
delete (newObject as any)[key];
}
}
return newObject;
}
export function compact(value: object): object {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const newObj: any = {};
for (const [key, val] of Object.entries(value)) {
if (val !== null && val !== undefined) {
if (typeof val === 'object') {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-argument
newObj[key] = compact(val);
} else {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
newObj[key] = val;
}
}
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return newObj;
}
export function urlEncode(value: object) {
return new URLSearchParams(value as Record<string, string>).toString();
}
export const objectExtensions: ExtensionMap = {
typeName: 'Object',
functions: {
isEmpty,
merge,
hasField,
removeField,
removeFieldsContaining,
keepFieldsContaining,
compact,
urlEncode,
},
};

View File

@@ -0,0 +1,305 @@
/* eslint-disable @typescript-eslint/unbound-method */
// import { createHash } from 'crypto';
import * as ExpressionError from '../ExpressionError';
import type { ExtensionMap } from './Extensions';
import CryptoJS from 'crypto-js';
import { encode } from 'js-base64';
import { transliterate } from 'transliteration';
const hashFunctions: Record<string, typeof CryptoJS.MD5> = {
md5: CryptoJS.MD5,
sha1: CryptoJS.SHA1,
sha224: CryptoJS.SHA224,
sha256: CryptoJS.SHA256,
sha384: CryptoJS.SHA384,
sha512: CryptoJS.SHA512,
sha3: CryptoJS.SHA3,
ripemd160: CryptoJS.RIPEMD160,
};
// All symbols from https://www.xe.com/symbols/ as for 2022/11/09
const CURRENCY_REGEXP =
/(\u004c\u0065\u006b|\u060b|\u0024|\u0192|\u20bc|\u0042\u0072|\u0042\u005a\u0024|\u0024\u0062|\u004b\u004d|\u0050|\u043b\u0432|\u0052\u0024|\u17db|\u00a5|\u20a1|\u006b\u006e|\u20b1|\u004b\u010d|\u006b\u0072|\u0052\u0044\u0024|\u00a3|\u20ac|\u00a2|\u0051|\u004c|\u0046\u0074|\u20b9|\u0052\u0070|\ufdfc|\u20aa|\u004a\u0024|\u20a9|\u20ad|\u0434\u0435\u043d|\u0052\u004d|\u20a8|\u20ae|\u004d\u0054|\u0043\u0024|\u20a6|\u0042\u002f\u002e|\u0047\u0073|\u0053\u002f\u002e|\u007a\u0142|\u006c\u0065\u0069|\u20bd|\u0414\u0438\u043d\u002e|\u0053|\u0052|\u0043\u0048\u0046|\u004e\u0054\u0024|\u0e3f|\u0054\u0054\u0024|\u20ba|\u20b4|\u0024\u0055|\u0042\u0073|\u20ab|\u005a\u0024)/gu;
const DOMAIN_REGEXP = /^[a-zA-Z0-9][a-zA-Z0-9-]{1,61}[a-zA-Z0-9](?:\.[a-zA-Z]{2,})+$/;
// This won't validate or catch literally valid email address, just what most people
// would expect
const EMAIL_REGEXP =
/(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@(?<domain>(\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))/;
// This also might not catch every possible URL
const URL_REGEXP =
/https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{2,}\b([-a-zA-Z0-9()\[\]@:%_\+.~#?&//=]*)/;
const CHAR_TEST_REGEXP = /\p{L}/u;
const PUNC_TEST_REGEXP = /[!?.]/;
const TRUE_VALUES = ['true', '1', 't', 'yes', 'y'];
const FALSE_VALUES = ['false', '0', 'f', 'no', 'n'];
function encrypt(value: string, extraArgs?: unknown): string {
const [format = 'MD5'] = extraArgs as string[];
if (format.toLowerCase() === 'base64') {
// We're using a library instead of btoa because btoa only
// works on ASCII
return encode(value);
}
const hashFunction = hashFunctions[format.toLowerCase()];
if (!hashFunction) {
throw new ExpressionError.ExpressionExtensionError(
`Unknown encrypt type ${format}. Available types are: ${Object.keys(hashFunctions)
.map((s) => s.toUpperCase())
.join(', ')}, and Base64.`,
);
}
return hashFunction(value.toString()).toString();
// return createHash(format).update(value.toString()).digest('hex');
}
function getOnlyFirstCharacters(value: string, extraArgs: number[]): string {
const [end] = extraArgs;
if (typeof end !== 'number') {
throw new ExpressionError.ExpressionExtensionError(
'getOnlyFirstCharacters() requires a argument',
);
}
return value.slice(0, end);
}
function isBlank(value: string): boolean {
return value === '';
}
function isPresent(value: string): boolean {
return !isBlank(value);
}
function length(value: string): number {
return value.length;
}
function removeMarkdown(value: string): string {
let output = value;
try {
output = output.replace(/^([\s\t]*)([*\-+]|\d\.)\s+/gm, '$1');
output = output
// Header
.replace(/\n={2,}/g, '\n')
// Strikethrough
.replace(/~~/g, '')
// Fenced codeblocks
.replace(/`{3}.*\n/g, '');
output = output
// Remove HTML tags
.replace(/<[\w|\s|=|'|"|:|(|)|,|;|/|0-9|.|-]+[>|\\>]/g, '')
// Remove setext-style headers
.replace(/^[=-]{2,}\s*$/g, '')
// Remove footnotes?
.replace(/\[\^.+?\](: .*?$)?/g, '')
.replace(/\s{0,2}\[.*?\]: .*?$/g, '')
// Remove images
.replace(/!\[.*?\][[(].*?[\])]/g, '')
// Remove inline links
.replace(/\[(.*?)\][[(].*?[\])]/g, '$1')
// Remove Blockquotes
.replace(/>/g, '')
// Remove reference-style links?
.replace(/^\s{1,2}\[(.*?)\]: (\S+)( ".*?")?\s*$/g, '')
// Remove atx-style headers
.replace(/^#{1,6}\s*([^#]*)\s*(#{1,6})?/gm, '$1')
.replace(/([*_]{1,3})(\S.*?\S)\1/g, '$2')
.replace(/(`{3,})(.*?)\1/gm, '$2')
.replace(/^-{3,}\s*$/g, '')
.replace(/`(.+?)`/g, '$1')
.replace(/\n{2,}/g, '\n\n');
} catch (e) {
return value;
}
return output;
}
function sayHi(value: string) {
return `hi ${value}`;
}
function stripTags(value: string): string {
return value.replace(/<[^>]*>?/gm, '');
}
function toDate(value: string): Date {
return new Date(value.toString());
}
function urlDecode(value: string, extraArgs: boolean[]): string {
const [entireString = false] = extraArgs;
if (entireString) {
return decodeURI(value.toString());
}
return decodeURIComponent(value.toString());
}
function urlEncode(value: string, extraArgs: boolean[]): string {
const [entireString = false] = extraArgs;
if (entireString) {
return encodeURI(value.toString());
}
return encodeURIComponent(value.toString());
}
function toInt(value: string, extraArgs: Array<number | undefined>) {
const [radix] = extraArgs;
return parseInt(value.replace(CURRENCY_REGEXP, ''), radix);
}
function toFloat(value: string) {
return parseFloat(value.replace(CURRENCY_REGEXP, ''));
}
function quote(value: string, extraArgs: string[]) {
const [quoteChar = '"'] = extraArgs;
return `${quoteChar}${value
.replace(/\\/g, '\\\\')
.replace(new RegExp(`\\${quoteChar}`, 'g'), `\\${quoteChar}`)}${quoteChar}`;
}
function isTrue(value: string) {
return TRUE_VALUES.includes(value.toLowerCase());
}
function isFalse(value: string) {
return FALSE_VALUES.includes(value.toLowerCase());
}
function isNumeric(value: string) {
return !isNaN(value as unknown as number) && !isNaN(parseFloat(value));
}
function isUrl(value: string) {
let url: URL;
try {
url = new URL(value);
} catch (_error) {
return false;
}
return url.protocol === 'http:' || url.protocol === 'https:';
}
function isDomain(value: string) {
return DOMAIN_REGEXP.test(value);
}
function isEmail(value: string) {
return EMAIL_REGEXP.test(value);
}
function stripSpecialChars(value: string) {
return transliterate(value, { unknown: '?' });
}
function toTitleCase(value: string) {
return value.replace(/\w\S*/g, (v) => v.charAt(0).toLocaleUpperCase() + v.slice(1));
}
function toSentenceCase(value: string) {
let current = value.slice();
let buffer = '';
while (CHAR_TEST_REGEXP.test(current)) {
const charIndex = current.search(CHAR_TEST_REGEXP);
current =
current.slice(0, charIndex) +
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
current[charIndex]!.toLocaleUpperCase() +
current.slice(charIndex + 1).toLocaleLowerCase();
const puncIndex = current.search(PUNC_TEST_REGEXP);
if (puncIndex === -1) {
buffer += current;
current = '';
break;
}
buffer += current.slice(0, puncIndex + 1);
current = current.slice(puncIndex + 1);
}
return buffer;
}
function toSnakeCase(value: string) {
return value
.toLocaleLowerCase()
.replace(/[ \-]/g, '_')
.replace(/[\u2000-\u206F\u2E00-\u2E7F\\'!"#$%&()*+,.\/:;<=>?@\[\]^`{|}~]/g, '');
}
function extractEmail(value: string) {
const matched = EMAIL_REGEXP.exec(value);
if (!matched) {
return undefined;
}
return matched[0];
}
function extractDomain(value: string) {
if (isEmail(value)) {
const matched = EMAIL_REGEXP.exec(value);
// This shouldn't happen
if (!matched) {
return undefined;
}
return matched.groups?.domain;
} else if (isUrl(value)) {
return new URL(value).hostname;
}
return undefined;
}
function extractUrl(value: string) {
const matched = URL_REGEXP.exec(value);
if (!matched) {
return undefined;
}
return matched[0];
}
export const stringExtensions: ExtensionMap = {
typeName: 'String',
functions: {
encrypt,
hash: encrypt,
getOnlyFirstCharacters,
removeMarkdown,
sayHi,
stripTags,
toBoolean: isTrue,
toDate,
toDecimalNumber: toFloat,
toFloat,
toInt,
toWholeNumber: toInt,
toSentenceCase,
toSnakeCase,
toTitleCase,
urlDecode,
urlEncode,
quote,
stripSpecialChars,
length,
isDomain,
isEmail,
isTrue,
isFalse,
isNotTrue: isFalse,
isNumeric,
isUrl,
isURL: isUrl,
isBlank,
isPresent,
extractEmail,
extractDomain,
extractUrl,
},
};

View File

@@ -0,0 +1,7 @@
// eslint-disable-next-line import/no-cycle
export {
extend,
hasExpressionExtension,
hasNativeMethod,
extendTransform,
} from './ExpressionExtension';