From 4c2d18b571dc70f45b06df6f197942acb2d63733 Mon Sep 17 00:00:00 2001 From: brocococonut Date: Mon, 25 Sep 2023 13:16:41 +1000 Subject: [PATCH] Add custom function support for case matching Update readome Move types out to type files --- README.md | 300 +++++++++++++++++++++++++-------------- deno.json | 10 +- mod.ts | 9 +- test/translation.test.ts | 51 +++++++ types/fn.ts | 7 + types/format.ts | 18 +++ util/format.ts | 57 +++++--- util/function.ts | 76 ++++++---- 8 files changed, 366 insertions(+), 162 deletions(-) create mode 100644 types/fn.ts create mode 100644 types/format.ts diff --git a/README.md b/README.md index 4f0f599..a8c3278 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,3 @@ - # locale-kit A small library to handle translations and localisations. It may be rough around the edges for now, but suits my needs (and hopefully yours) decently enough. @@ -9,10 +8,10 @@ A small library to handle translations and localisations. It may be rough around For support, feel free to open an issue! - ## Installation Install locale-kit by adding the import and initializing the class with your translation/language config. + ```ts import { LocaleKit } from "https://deno.land/x/localekit/mod.ts"; ``` @@ -20,8 +19,9 @@ import { LocaleKit } from "https://deno.land/x/localekit/mod.ts"; ## Usage/Examples #### Example `main.ts` file + ```ts -import { LocaleKit } from "https://deno.land/x/localekit/mod.ts"; +import { LocaleKit } from "https://deno.land/x/localekit/mod.ts"; // Or from '@locale-kit/locale-kit' if using NPM and Node import locale_config from "./locale.config.ts"; const locale = new LocaleKit({ @@ -37,54 +37,58 @@ console.log(locale.t("common.languages.en")); // Will output "English" as the fa ``` #### Example `locale.config.ts` file + ```ts export default { en: { common: { languages: { en: "English", - es: "Spanish" - } - } + es: "Spanish", + }, + }, }, es: { common: { languages: { en: "Inglés", - es: "Español" - } - } - } + es: "Español", + }, + }, + }, }; ``` #### Example with formatting and data replacement + What translation tool would be feature-complete(ish) without a formatter and data inserter? The general formatting of a dynamic translation key follows the following: -* starts with `[[~` -* ends with `]]` -* first parameter is a key on the params object (can be as deeply nested as you want) and must be wrapped in curly braces; examples: - * `{a_key_on_the_root_object}` - root level key - * `{parent.child_key}` - Nested key - * `{key.deeply.nested.0.and_in_array}` - Nested deeply and within an array - * `{key.still.2.1.3.nested.deeply.}` - Nested deeply within multi-dimensional arrays -* next parameter onwards is a format option key. The key can be a string but can not include spaces but only supports the regex values `\w-`; examples: - * valid: test_case - * valid: test-case - * valid: testCase - * invalid: test case - * invalid: test(case) - * invalid: test.case -* key followed by colon, and value of test case is wrapped in backticks (or with `;:` and `:;` at the start and end if you prefer your templates/dynamic structures to be able to span multiple lines without having to escape the backtics in javascript) -* each parameter should be separated by a lone pipe character -* optionally, a `default` case can be passed in at the end to handle edge cases -* optionally, you can embed a value in a format case using double squigly lines; examples: - * `test_case: `\`Here's my embeded data {{data_key}}\`` -* optionally, embedded values can have fallback strings spanning multiple lines. They should be surrounded by double pipes, and can't contain double pipes; examples: - * `{{key}}||oops, nothing here||` + +- starts with `[[~` +- ends with `]]` +- first parameter is a key on the params object (can be as deeply nested as you want) and must be wrapped in curly braces; examples: + - `{a_key_on_the_root_object}` - root level key + - `{parent.child_key}` - Nested key + - `{key.deeply.nested.0.and_in_array}` - Nested deeply and within an array + - `{key.still.2.1.3.nested.deeply.}` - Nested deeply within multi-dimensional arrays +- next parameter onwards is a format option key. The key can be a string but can not include spaces but only supports the regex values `\w-`; examples: + - valid: test_case + - valid: test-case + - valid: testCase + - invalid: test case + - invalid: test(case) + - invalid: test.case +- key followed by colon, and value of test case is wrapped in backticks (or with `;:` and `:;` at the start and end if you prefer your templates/dynamic structures to be able to span multiple lines without having to escape the backtics in javascript) +- each parameter should be separated by a lone pipe character +- optionally, a `default` case can be passed in at the end to handle edge cases +- optionally, you can embed a value in a format case using double squigly braces; examples: + - `test_case: `\`Here's my embeded data: {{data_key}}\`` +- optionally, embedded values can have fallback strings spanning multiple lines. They should be surrounded by double pipes, and can't contain double pipes; examples: + - `{{key}}||oops, nothing here||` If we put all that together and format it to our liking, we get something like the following: (new lines should be treated as spaces) + ``` [[~ {key} @@ -93,35 +97,39 @@ If we put all that together and format it to our liking, we get something like t default: `oops, no cases matched` ]] ``` + The above would only look pretty outside of a JSON file though. The return characters are purely decorative outside of the case formats. Additionally, you can replace the backticks in the case values with `;:` and `:;` at the start and end if you prefer your templates/dynamic structures to be able to span multiple lines without having to escape the backtics in javascript. An example of this would be: + ```ts const str = `Lorem Ipsum [[~ {key} test_case_1: ;:test 2:; | test_case_2: ;:{{key}} {{key2}}||no key found||:; | default: ;:oops, no cases matched:; -]]` +]]`; // ----- compared to ----- // const str = `Lorem Ipsum [[~ {key} test_case_1: \`test 2\` | test_case_2: \`{{key}} {{key2}}||no key found||\` | default: \`oops, no cases matched\`; -]]` +]]`; ``` -The regex that handles the parsing of the above can be found in `/mod.ts`. +The regex that handles the parsing of the above can be found in `/util/format.ts`. Here's the regex to save you the trouble though: + ```ts const DYN_STR_REGEX = - /\[\[~\s*(?:{(?.*?)})\s*(?(?:\s*(?(?:(?:[\w-])|(?:N?GTE?|N?LTE?|N?EQ|AND|N?BT|N?IN|X?OR)\((?:[^)]+)\))+)\s*:\s*(?:(?:`[^`]*`)|(?:;:(?:(?!:;).)*:;))\s*\|*\s*)+)+\]\]/gs; + /\[\[~\s*(?:{(?.*?)})\s*(?(?:\s*(?(?:(?:[\w-])|(?:(?:LEN_)?N?GTE?|(?:LEN_)?N?LTE?|(?:LEN_)?N?EQ|(?:LEN_)?N?BT|(?:LEN_)?N?IN|CUSTOM|(?:LEN_)?X?OR|(?:LEN_)?AND)\((?:[^)]+)\))+)\s*:\s*(?:(?:`[^`]*`)|(?:;:(?:(?!:;).)*:;))\s*\|*\s*)+)+\]\]/gs; ``` Here's an example properly showing how this would be used in the real-world: + ```ts // /locale.config.ts -... +... common: { counter: 'You have [[~ {count} 0: `no apples` | 1: `one apple` | GTE(25): `so many ({{count}}) apples` default: `{{count}} apples` ]]' } @@ -142,11 +150,14 @@ console.log(locale.t("common.counter", { count: 1 })); // Will output "You have console.log(locale.t("common.counter", { count: 10 })); // Will output "You have so 10 apples" console.log(locale.t("common.counter", { count: 27 })); // Will output "You have so many (27) apples" ``` + #### Example with formatting and data replacement, as well as data fallback + If a value doesn't exist for the provided key, you can provide a fallback value. This can be anything but must not contain the sequence `||` and must begin and end with `||` + ```ts // /locale.config.ts -... +... common: { counter: 'You have [[~ {count} 0: `no apples` | 1: `one apple` | default: `{{count}}||inappropriate|| apples` ]]' } @@ -168,13 +179,16 @@ console.log(locale.t("common.counter", { other_key: 10 })); // Will output "You ``` ### Function case keys for further filtering + We'll start off with a direct example from the tests file: + ```ts const svc = new LanguageService(); locale.addLanguage("en", { common: { - test_age: "You are [[~ {age} LTE(num:12): `a child` | BT(num:12, num:18): `a teenager` | GTE(num:18): `an adult` ]]" + test_age: + "You are [[~ {age} LTE(num:12): `a child` | BT(num:12, num:18): `a teenager` | GTE(num:18): `an adult` ]]", }, }); @@ -185,38 +199,72 @@ assertEquals(svc.t("common.test_age", { age: 8 }), "You are a child"); Each sequentual assertion would provide the valid answers. The functions get run in sequential order, exiting once one returns true. -There's 17 comparison functions in total, most are just variants of each other though: +There's 17 (and double that to 34 for length operation) comparison functions in total, most are just variants of each other though: + ```typescript [ - "GT", "GTE", "NGT", "NGTE", // greater than functions - "LT", "LTE", "NLT", "NLTE", // less than functions - "EQ", "NEQ", // equality functions (strict === and !==) - "AND", // checking for two boolean values - "BT", "NBT", // between two numbers (non-inclusive 15 is not between 10-15) - "IN", "NIN", // array functions, checks to see if the current variable is inside a provided options key array - "OR", "XOR" // standard or functions (a || b, a !== b) -] + "GT", + "GTE", + "NGT", + "NGTE", + "LEN_GT", + "LEN_GTE", + "LEN_NGT", + "LEN_NGTE", // greater than functions + "LT", + "LTE", + "NLT", + "NLTE", + "LT", + "LTE", + "NLT", + "NLTE", // less than functions + "EQ", + "NEQ", + "LEN_EQ", + "LEN_NEQ", // equality functions (strict === and !==) + "AND", + "LEN_AND", // checking for two boolean values + "BT", + "NBT", + "LEN_BT", + "LEN_NBT", // between two numbers (non-inclusive 15 is not between 10-15) + "IN", + "NIN", + "LEN_IN", + "LEN_NIN", // array functions, checks to see if the current variable is inside a provided options key array + "OR", + "XOR", + "LEN_OR", + "LEN_XOR", // standard or functions (a || b, !!a !== !!b) + "CUSTOM", +]; ``` + So a readout of all of those would be: -* greater than -* greater than or equal to -* not greater than -* not greater than or equal to -* less than -* less than or equal to -* not less than -* not less than or equal to -* equal to (strict) -* not equal to (strict) -* and (&&) -* between (GT(a, b) && LT(a, c)) -* not between -* in (string or array .includes) -* not in -* or (||) -* xor (a !== b, strict) + +- greater than +- greater than or equal to +- not greater than +- not greater than or equal to +- less than +- less than or equal to +- not less than +- not less than or equal to +- equal to (strict) +- not equal to (strict) +- and (&&) +- between (GT(a, b) && LT(a, c)) +- not between +- in (string or array .includes) +- not in +- or (||) +- xor (!!a !== !!b, strict) +- custom function ((a: unknown) => boolean) +- Then length versions for each that that check string lengths, array lengths, or that an object has a number of keys The first parameter (which we'll call `a`) is always passed in by the dynamic replacer as the parameter at the start of the statement + ``` [[~ {key.child.child} <---- this @@ -226,16 +274,19 @@ The first parameter (which we'll call `a`) is always passed in by the dynamic re The second parameter (and third if it requires one) are ones passed in by you (`b`, and `c` respectively) Each parameter is prefixed by its type to aid the parser. The prefixes available are: -* num - a simple number type, parsed as either an int or a float depending on if a period is detected. The answer is thrown out if `Number.isNaN` returns true. -* str - A string -* bool - a boolean value. this can be written either as: bool:1, or bool:true (and their false counterparts) -* key - a value that should be fetched from the options object you passed in. This is handled the same way as the afformentioned parameter `a` at the start of the statement + +- num - a simple number type, parsed as either an int or a float depending on if a period is detected. The answer is thrown out if `Number.isNaN` returns true. +- str - A string +- bool - a boolean value. this can be written either as: bool:1, or bool:true (and their false counterparts) +- key - a value that should be fetched from the options object you passed in. This is handled the same way as the afformentioned parameter `a` at the start of the statement Spacing doesn't matter when writing the function, it can be formatted in a number of ways to help with readability. eg: -* GT(num:1) `test` -* GT( num: 1 ) `test` -* GT(num : 1) `test` -Or even: + +- GT(num:1) `test` +- GT( num: 1 ) `test` +- GT(num : 1) `test` + Or even: + ``` GT( num: 1 @@ -243,58 +294,89 @@ GT( ``` Each function has a list of available types to use for each parameter, these apply to afformentioned parameters `b`, and `c`. -| fn group | param: b | param: c | +| fn group | param: b | param: c | |--------------------|---------------------|---------------| -| GT, GTE, NGT, NGTE | num, str, key | N/A | -| LT, LTE, NLT, NLTE | num, str, key | N/A | -| EQ, NEQ | num, str, key, bool | N/A | -| BT, NBT | num, str, key | num, str, key | -| AND, OR, XOR | num, str, key, bool | N/A | -| IN, NIN | key | N/A | +| GT, GTE, NGT, NGTE | num, str, key | N/A | +| LT, LTE, NLT, NLTE | num, str, key | N/A | +| EQ, NEQ | num, str, key, bool | N/A | +| BT, NBT | num, str, key | num, str, key | +| AND, OR, XOR | num, str, key, bool | N/A | +| IN, NIN | key | N/A | +| CUSTOM | fn | N/A | _You'll have to be careful when passing in user data as the statement starting parameter as this isn't checked like the above._ On top of casting the type before the value, the different types also have their values wrapped differently: -* strings: ``` str: `test` ``` (backtick wrapped string. you can put anything inside other than backticks) -* numbers: `num: 1` || `num: 1.1` -* booleans: `bool: 1` || `bool: 0` || `bool: false` || `bool: true` -* values of keys: `key: {key1.child_key}` + +- strings: `` str: `test` `` (backtick wrapped string. you can put anything inside other than backticks) +- numbers: `num: 1` || `num: 1.1` +- booleans: `bool: 1` || `bool: 0` || `bool: false` || `bool: true` +- values of keys: `key: {key1.child_key}` +- function to call: `fn: {key.of.function}` (When you pass in a second object, this gets used to find functions) Parameters should be separated by a comma - but again, spacing doesn't matter here. +When using a `CUSTOM` function for case checking, the function should return a true or false value (it gets !! applied to it anyway). The function receives the following parameters: + +```ts +/** + * @param val The value from the matched key in the overall [[~ {key} ]] block + * @param ctx The full object passed in to the block (where the val key is matched) + * @param matched The [[~ {key} ]] block that was matched as astring + */ +type Fn = ( + val: unknown, + ctx: Record, + matched: string +) => boolean; +``` + An example with two given parameters can be found in the test `Translate a key and handle function calls`. Here's the test separated out from the other one in there though: + ```ts -Deno.test( - { name: "Translate a key and handle function calls" }, - () => { - const svc = new LocaleKit(); - - svc.addLanguage("en", { - common: { - test_array: "You have [[~ {messages.length} EQ(num:0): `no` | LTE(num:3): `some` | LTE(num:8): `a few` | LTE(num:40): `a lot of` | GTE(num:41): `too many` | default: `{{messages.length}}` ]] messages", - }, - }); - - const arr = new Array(0); - - assertEquals(svc.t("common.test_array", { messages: arr }), "You have no messages"); - arr.length = 3 - assertEquals(svc.t("common.test_array", { messages: arr }), "You have some messages"); - arr.length = 8 - assertEquals(svc.t("common.test_array", { messages: arr }), "You have a few messages"); - arr.length = 40 - assertEquals(svc.t("common.test_array", { messages: arr }), "You have a lot of messages"); - arr.length = 41 - assertEquals(svc.t("common.test_array", { messages: arr }), "You have too many messages"); - } -); +Deno.test({ name: "Translate a key and handle function calls" }, () => { + const svc = new LocaleKit(); + + svc.addLanguage("en", { + common: { + test_array: + "You have [[~ {messages.length} EQ(num:0): `no` | LTE(num:3): `some` | LTE(num:8): `a few` | LTE(num:40): `a lot of` | GTE(num:41): `too many` | default: `{{messages.length}}` ]] messages", + }, + }); + + const arr = new Array(0); + + assertEquals( + svc.t("common.test_array", { messages: arr }), + "You have no messages" + ); + arr.length = 3; + assertEquals( + svc.t("common.test_array", { messages: arr }), + "You have some messages" + ); + arr.length = 8; + assertEquals( + svc.t("common.test_array", { messages: arr }), + "You have a few messages" + ); + arr.length = 40; + assertEquals( + svc.t("common.test_array", { messages: arr }), + "You have a lot of messages" + ); + arr.length = 41; + assertEquals( + svc.t("common.test_array", { messages: arr }), + "You have too many messages" + ); +}); ``` + ## Authors - [@brocococonut](https://www.github.com/brocococonut) - ## License [MIT](https://choosealicense.com/licenses/mit/) - diff --git a/deno.json b/deno.json index 866d6bc..98e2818 100644 --- a/deno.json +++ b/deno.json @@ -12,10 +12,12 @@ } }, "test": { - "files": { - "include": [], - "exclude": ["npm_build/"] - } + "include": ["test/"], + "exclude": ["npm_build/"] + }, + "bench": { + "include": ["test/"], + "exclude": ["npm_build/"] }, "compilerOptions": {} } diff --git a/mod.ts b/mod.ts index 90cf8b0..936da24 100644 --- a/mod.ts +++ b/mod.ts @@ -1,3 +1,4 @@ +import { FunctionType } from "./types/fn.ts"; import { format } from "./util/format.ts"; import { flattenObject } from "./util/obj.ts"; @@ -138,14 +139,18 @@ export class LocaleKit { * @param opts Options for the translation (lang, data, etc.) * @returns The translated string */ - t(key: string, opts?: Record) { + t>( + key: string, + opts?: T, + functions?: Record>, + ) { // Make sure the langauge is supported const found = this.getKey( (opts?.lang || this.fallback_language) as string, key, ); - return format(found, opts || {}); + return format(found, opts || ({} as T), functions || {}); } /** diff --git a/test/translation.test.ts b/test/translation.test.ts index c26957f..1354ca5 100644 --- a/test/translation.test.ts +++ b/test/translation.test.ts @@ -4,6 +4,7 @@ import { } from "https://deno.land/std@0.160.0/testing/asserts.ts"; import { LocaleKit } from "../mod.ts"; +import { FunctionType } from "../types/fn.ts"; Deno.test("Init with language", () => { const svc = new LocaleKit({ @@ -524,3 +525,53 @@ Deno.test({ name: "Translate a key and handle function calls" }, () => { "You have too many messages", ); }); + +// Create a test for when a case has a function argument +Deno.test( + { + name: + "Translate a key and handle function calls that have function parameters", + }, + () => { + const svc = new LocaleKit(); + + svc.addLanguage("en", { + common: { + test_age: + "You are [[~ {age} CUSTOM(fn: {isChild}): `a child` | CUSTOM(fn: {isTeen}): `a teenager` | CUSTOM(fn: {isAdult}): `an adult` ]]", + }, + }); + + const fns: Record = { + isChild: (age: unknown, _ctx, _matched) => { + return (age as number) <= 12; + }, + isTeen: (age: unknown, _ctx, _matched) => { + return (age as number) > 12 && (age as number) < 18; + }, + isAdult: (age: unknown, _ctx, _matched) => { + return (age as number) >= 18; + }, + }; + + assertEquals( + svc.t("common.test_age", { age: 18 }, fns), + "You are an adult", + ); + assertEquals( + svc.t("common.test_age", { age: 13 }, fns), + "You are a teenager", + ); + assertEquals(svc.t("common.test_age", { age: 8 }, fns), "You are a child"); + assertEquals(svc.t("common.test_age", { age: 0 }, fns), "You are a child"); + assertEquals(svc.t("common.test_age", { age: 12 }, fns), "You are a child"); + assertEquals( + svc.t("common.test_age", { age: 17 }, fns), + "You are a teenager", + ); + assertEquals( + svc.t("common.test_age", { age: 18 }, fns), + "You are an adult", + ); + }, +); diff --git a/types/fn.ts b/types/fn.ts new file mode 100644 index 0000000..38867e9 --- /dev/null +++ b/types/fn.ts @@ -0,0 +1,7 @@ +export type FunctionType< + T extends Record = Record, +> = ( + val: unknown, + ctx: T, + matched: string, +) => unknown; diff --git a/types/format.ts b/types/format.ts new file mode 100644 index 0000000..34ac79b --- /dev/null +++ b/types/format.ts @@ -0,0 +1,18 @@ +export type ArgType = number | string; + +export type FuncParamType = "num" | "str" | "key" | "bool" | "fn"; + +/** + * A map of functions, how many args and their types they take, and their names + */ +export interface FUNCSMapType { + [func_name: string]: { + arg_count: number; + arg_types: [ + (FuncParamType)[], + (FuncParamType)[]?, + ]; + // deno-lint-ignore no-explicit-any + func: (...args: any) => boolean; + }; +} diff --git a/util/format.ts b/util/format.ts index 23eb24b..584f1a4 100644 --- a/util/format.ts +++ b/util/format.ts @@ -1,29 +1,37 @@ import { getNestedKeyValue } from "./obj.ts"; import { FUNC_NAMES, FUNCS, getFunctionParameters } from "./function.ts"; +import { FunctionType } from "../types/fn.ts"; +import { ArgType } from "../types/format.ts"; const DYN_STR_REGEX = - /\[\[~\s*(?:{(?.*?)})\s*(?(?:\s*(?(?:(?:[\w-])|(?:(?:LEN_)?N?GTE?|(?:LEN_)?N?LTE?|(?:LEN_)?N?EQ|(?:LEN_)?N?BT|(?:LEN_)?N?IN|(?.*?)})\s*(?(?:\s*(?(?:(?:[\w-])|(?:(?:LEN_)?N?GTE?|(?:LEN_)?N?LTE?|(?:LEN_)?N?EQ|(?:LEN_)?N?BT|(?:LEN_)?N?IN|CUSTOM|(?:LEN_)?X?OR|(?:LEN_)?AND)\((?:[^)]+)\))+)\s*:\s*(?:(?:`[^`]*`)|(?:;:(?:(?!:;).)*:;))\s*\|*\s*)+)+\]\]/gs; const DYN_CASEKEY_REGEX = - /(?(?:(?:[\w-])|(?:(?:LEN_)?N?GTE?|(?:LEN_)?N?LTE?|(?:LEN_)?N?EQ|(?:LEN_)?N?BT|(?:LEN_)?N?IN|(?[^`]*)`)|(?:;:(?(?:(?!:;).)*):;))/gs; + /(?(?:(?:[\w-])|(?:(?:LEN_)?N?GTE?|(?:LEN_)?N?LTE?|(?:LEN_)?N?EQ|(?:LEN_)?N?BT|(?:LEN_)?N?IN|CUSTOM|(?:LEN_)?X?OR|(?:LEN_)?AND)\((?:[^)]+)\))+)\s*:\s*(?:(?:`(?[^`]*)`)|(?:;:(?(?:(?!:;).)*):;))/gs; /** * Function for handling */ const DYN_FUNC_REGEX = - /(?(?:LEN_)?N?GTE?|(?:LEN_)?N?LTE?|(?:LEN_)?N?EQ|(?:LEN_)?N?BT|(?:LEN_)?N?IN|(?[^)]+)\)/s; + /(?(?:LEN_)?N?GTE?|(?:LEN_)?N?LTE?|(?:LEN_)?N?EQ|(?:LEN_)?N?BT|(?:LEN_)?N?IN|CUSTOM|(?:LEN_)?X?OR|(?:LEN_)?AND)\((?[^)]+)\)/s; /** * Regex for inner content replacement */ const DYN_NESTED_REGEX = /\{\{(.*?)\}\}(?:\|\|(.*?)\|\|)?/gs; -export function format(str = "", opts: Record = {}) { +export function format>( + str = "", + data?: T, + functions?: Record>, +) { + const ctx = data || ({} as T); + // Find anything matching something similar to [[~ {object.nested.key} 1: `string` | 2: `{{object.second.nested.key}} string` | 3: `string` | ... | default: `string` ]] // and replace it with the correct string depending on the value of the object.nested.key const translated = str.replaceAll( DYN_STR_REGEX, ( - _matched_str: string, + matched_str: string, _key: string, _dyn_field: string, _case_key: string, @@ -31,7 +39,7 @@ export function format(str = "", opts: Record = {}) { _src_str: string, groups: { data_key: string; case_key: string; cases: string }, ) => { - let cur_val = getNestedKeyValue(opts, groups["data_key"]); + let cur_val = getNestedKeyValue(ctx, groups["data_key"]); if (cur_val === undefined) { cur_val = "default"; @@ -66,9 +74,14 @@ export function format(str = "", opts: Record = {}) { return; } - const [arg1_obj, arg2_obj] = getFunctionParameters(key, opts); - const arg1 = arg1_obj?.val; - const arg2 = arg2_obj?.val; + // Fetch the function parameters + const [arg1_obj, arg2_obj] = getFunctionParameters( + key, + ctx, + functions, + ); + let arg1 = arg1_obj?.val; + let arg2 = arg2_obj?.val; const arg1_type = arg1_obj?.type; const arg2_type = arg2_obj?.type; @@ -99,13 +112,25 @@ export function format(str = "", opts: Record = {}) { return; } + if (arg1_type === "fn") { + const fn_to_be_wrapped = arg1 as FunctionType; + // Wrap the function in a new function that returns a boolean, also give the wrapped function a callback to return a value + arg1 = (a: unknown) => { + return !!fn_to_be_wrapped(a, ctx, matched_str); + }; + } + + if (arg2_type === "fn") { + const fn_to_be_wrapped = arg2 as FunctionType; + arg2 = (a: unknown) => { + return !!fn_to_be_wrapped(a, ctx, matched_str); + }; + } + // If the types are valid, run the function - const result = - (func_obj.func as (a: string, b: string, c: string) => boolean)( - cur_val as string, - arg1 as string, - arg2 as string, - ); + const result = ( + func_obj.func as (a: string, b: string, c: string) => boolean + )(cur_val as string, arg1 as string, arg2 as string); // If the result is true, set the value of this key to the // external func_case_val variable for further use @@ -140,7 +165,7 @@ export function format(str = "", opts: Record = {}) { const formatted = translated.replaceAll( DYN_NESTED_REGEX, (_match, key, fallback) => { - const val = getNestedKeyValue(opts, key); + const val = getNestedKeyValue(ctx, key); if (val === undefined) { return fallback || "[no_value]"; } diff --git a/util/function.ts b/util/function.ts index 77d3ff2..c29686d 100644 --- a/util/function.ts +++ b/util/function.ts @@ -1,12 +1,13 @@ +import { FunctionType } from "../types/fn.ts"; +import type { ArgType, FUNCSMapType } from "../types/format.ts"; +import { FuncParamType } from "../types/format.ts"; import { getNestedKeyValue } from "./obj.ts"; /** * Regex to get the 1 (or 2) arguments of a function */ const DYN_ARG_REGEX = - /(?(?:str\s*:\s*\`(?[^`]*)\`)|(?:key\s*:\s*(?:{(?.*?)}))|(?:num\s*:\s*(?[0-9]+(\.[0-9]+)?))|(?:bool\s*:\s*(?true|false|1|0)))(?:\s*,\s*(?(?:str\s*:\s*\`(?[^`]*)\`)|(?:key\s*:\s*(?:{(?.*?)}))|(?:num\s*:\s*(?[0-9]+(\.[0-9]+)?))|(?:bool\s*:\s*(?true|false|1|0))))?/s; - -type ArgType = number | string; + /(?(?:str\s*:\s*\`(?[^`]*)\`)|(?:key\s*:\s*(?:{(?.*?)}))|(?:fn\s*:\s*(?:{(?.*?)}))|(?:num\s*:\s*(?[0-9]+(\.[0-9]+)?))|(?:bool\s*:\s*(?true|false|1|0)))(?:\s*,\s*(?(?:str\s*:\s*\`(?[^`]*)\`)|(?:key\s*:\s*(?:{(?.*?)}))|(?:fn\s*:\s*(?:{(?.*?)}))|(?:num\s*:\s*(?[0-9]+(\.[0-9]+)?))|(?:bool\s*:\s*(?true|false|1|0))))?/s; const getLen = (a: ArgType): ArgType => { if (typeof a === "string" || Array.isArray(a)) { @@ -14,7 +15,7 @@ const getLen = (a: ArgType): ArgType => { } else if (typeof a === "object") { return Object.keys(a).length; } else { - return 0; + return Number.NaN; } }; @@ -28,14 +29,14 @@ const funcNLT = (a: ArgType, b: ArgType) => !funcLT(a, b); const funcNLTE = (a: ArgType, b: ArgType) => !funcLTE(a, b); const funcEQ = (a: ArgType | boolean, b: ArgType | boolean) => a === b; const funcNEQ = (a: ArgType | boolean, b: ArgType | boolean) => a !== b; -const funcAND = (a: boolean, b: boolean) => a && b; +const funcAND = (a: boolean, b: boolean) => !!a && !!b; const funcBT = (a: ArgType, b: ArgType, c: ArgType) => funcGT(a, b) && funcLT(a, c); const funcNBT = (a: ArgType, b: ArgType, c: ArgType) => !funcBT(a, b, c); const funcIN = (a: ArgType, b: ArgType[] | string) => b.includes(a as string); const funcNIN = (a: ArgType, b: ArgType[] | string) => !funcIN(a, b); -const funcOR = (a: boolean, b: boolean) => a || b; -const funcXOR = (a: boolean, b: boolean) => funcNEQ(a, b); +const funcOR = (a: boolean, b: boolean) => !!a || !!b; +const funcXOR = (a: boolean, b: boolean) => funcNEQ(!!a, !!b); const funcLEN_GT = (a: ArgType, b: ArgType) => getLen(a) > b; const funcLEN_GTE = (a: ArgType, b: ArgType) => getLen(a) >= b; @@ -57,23 +58,10 @@ const funcLEN_IN = (a: ArgType, b: ArgType[] | string) => b.includes(getLen(a) as string); const funcLEN_NIN = (a: ArgType, b: ArgType[] | string) => !funcIN(getLen(a), b); - -/** - * A map of functions, how many args and their types they take, and their names - */ -export interface FUNCSMapType { - [func_name: string]: { - arg_count: number; - arg_types: [ - ("num" | "str" | "bool" | "key")[], - ("num" | "str" | "bool" | "key")[]?, - ]; - // deno-lint-ignore no-explicit-any - func: (...args: any) => boolean; - }; -} +const funcCUSTOM = (a: ArgType, fn: (a: ArgType) => boolean) => fn(a); export const FUNCS: FUNCSMapType = { + // GT functions GT: { arg_count: 1, arg_types: [["num", "str", "key"]], @@ -246,52 +234,73 @@ export const FUNCS: FUNCSMapType = { arg_types: [["num", "str", "key", "bool"]], func: funcXOR, }, + CUSTOM: { + arg_count: 1, + arg_types: [["fn"]], + func: funcCUSTOM, + }, }; export const FUNC_NAMES = Object.keys(FUNCS); -export const getFunctionParameters = ( +export const getFunctionParameters = >( str: string, - opts: Record = {}, + data: Record = {}, + functions?: Record>, ) => { + const ctx = data || {}; + const fns = functions || {}; // Execute the regex to get the arguments from the string const { arg1, arg1_val_str, arg1_val_key, + arg1_val_fn, arg1_val_num, arg1_val_bool, arg2, arg2_val_str, arg2_val_key, + arg2_val_fn, arg2_val_num, arg2_val_bool, } = (DYN_ARG_REGEX.exec(str) || { groups: {} }).groups as Partial<{ arg1: string; arg1_val_str: string; arg1_val_key: string; + arg1_val_fn: string; arg1_val_num: string; arg1_val_bool: string; arg2: string; arg2_val_str: string; arg2_val_key: string; + arg2_val_fn: string; arg2_val_num: string; arg2_val_bool: string; }>; const args: ( - | { val: string | number | boolean; type: "str" | "num" | "bool" | "key" } + | { + val: + | string + | number + | boolean + | FunctionType + | ((a: unknown) => boolean); + type: FuncParamType; + } | undefined )[] = []; if (arg1) { - const type = arg1.split(":")[0] as "str" | "num" | "bool" | "key"; - let arg: string | number | boolean | undefined = undefined; + const type = arg1.split(":")[0] as FuncParamType; + let arg: string | number | boolean | FunctionType | undefined = + undefined; // handle the different types of arguments if (type === "str" && arg1_val_str !== undefined) { arg = arg1_val_str; } else if (type === "key" && arg1_val_key !== undefined) { - arg = getNestedKeyValue(opts, arg1_val_key as string) as + arg = getNestedKeyValue(ctx, arg1_val_key as string) as | string | number | boolean @@ -321,6 +330,8 @@ export const getFunctionParameters = ( arg = undefined; break; } + } else if (type === "fn" && arg1_val_fn !== undefined) { + arg = getNestedKeyValue(fns, arg1_val_fn as string) as FunctionType; } args.push(arg !== undefined ? { val: arg, type } : undefined); @@ -331,14 +342,15 @@ export const getFunctionParameters = ( // replacer) argument, parse it from the provided ar2 variables // from the regex if (arg2) { - const type = arg2.split(":")[0] as "str" | "num" | "bool" | "key"; - let arg: string | number | boolean | undefined = undefined; + const type = arg2.split(":")[0] as FuncParamType; + let arg: string | number | boolean | FunctionType | undefined = + undefined; // handle the different types of arguments if (type === "str" && arg2_val_str !== undefined) { arg = arg2_val_str; } else if (type === "key" && arg2_val_key !== undefined) { - arg = getNestedKeyValue(opts, arg2_val_key as string) as + arg = getNestedKeyValue(ctx, arg2_val_key as string) as | string | number | boolean @@ -365,6 +377,8 @@ export const getFunctionParameters = ( arg = undefined; break; } + } else if (type === "fn" && arg2_val_fn !== undefined) { + arg = getNestedKeyValue(fns, arg2_val_fn as string) as FunctionType; } args.push(arg ? { val: arg, type } : undefined);