From e782e6a70ff3362eaa4aa50321a3ed49c760e952 Mon Sep 17 00:00:00 2001 From: Jake Lees Date: Wed, 6 Mar 2024 02:27:37 +1100 Subject: [PATCH] Refactor code and add tests Change the function/class responsible for flattening objects. --- demo.ts | 16 +- deno.json | 38 +- mod.ts | 325 ++++++------ scripts/build_npm.ts | 44 +- test/flatten.test.ts | 303 +++++++++++ test/format.test.ts | 116 +++++ test/function.test.ts | 64 +++ test/translation.bench.ts | 140 +++-- test/translation.test.ts | 1028 +++++++++++++++++++------------------ types/fn.ts | 8 +- types/format.ts | 16 +- util/flatten.ts | 603 ++++++++++++++++++++++ util/format.ts | 331 ++++++------ util/function.ts | 667 ++++++++++++------------ util/obj.ts | 64 +-- 15 files changed, 2448 insertions(+), 1315 deletions(-) create mode 100644 test/flatten.test.ts create mode 100644 test/format.test.ts create mode 100644 test/function.test.ts create mode 100644 util/flatten.ts diff --git a/demo.ts b/demo.ts index ad065f6..6b0bf2d 100644 --- a/demo.ts +++ b/demo.ts @@ -1,14 +1,14 @@ import { LocaleKit } from "./mod.ts"; const locale = new LocaleKit({ - languages: { - en: { - test: { - key: "Test Key", - }, - }, - }, - fallback_language: "en", + languages: { + en: { + test: { + key: "Test Key", + }, + }, + }, + fallback_language: "en", }); console.log(locale.t("test.key")); diff --git a/deno.json b/deno.json index 98e2818..d138536 100644 --- a/deno.json +++ b/deno.json @@ -1,23 +1,43 @@ { "fmt": { - "exclude": ["README.md", "npm_build/"], + "exclude": [ + "README.md", + "npm_build/" + ], "options": {} }, "lint": { - "exclude": ["npm_build/"], + "exclude": [ + "npm_build/" + ], "rules": { - "tags": ["recommended"], + "tags": [ + "recommended" + ], "include": [], "exclude": [] } }, "test": { - "include": ["test/"], - "exclude": ["npm_build/"] + "include": [ + "test/" + ], + "exclude": [ + "npm_build/" + ] }, "bench": { - "include": ["test/"], - "exclude": ["npm_build/"] + "include": [ + "test/" + ], + "exclude": [ + "npm_build/" + ] }, - "compilerOptions": {} -} + "compilerOptions": {}, + "tasks": { + "lint": "deno run -A npm:@biomejs/biome lint **/*.ts", + "format": "deno run -A npm:@biomejs/biome format **/*.ts --write", + "check": "deno run -A npm:@biomejs/biome check --apply **/*.ts" + } +} \ No newline at end of file diff --git a/mod.ts b/mod.ts index 936da24..426744e 100644 --- a/mod.ts +++ b/mod.ts @@ -1,6 +1,6 @@ import { FunctionType } from "./types/fn.ts"; +import { Flattened } from "./util/flatten.ts"; import { format } from "./util/format.ts"; -import { flattenObject } from "./util/obj.ts"; /** * The main translation/language class. This handles storage of languages, @@ -8,161 +8,174 @@ import { flattenObject } from "./util/obj.ts"; * languages. */ export class LocaleKit { - /** - * A map of language codes and their given translations - */ - languages: { - [key: string]: { - [key: string]: string; - }; - } = {}; - /** - * The language code to fall back to if the language is not supported - */ - fallback_language = "en"; - /** - * A key to fall back to if the key is not found in the given language. - * If the fallback key is not found, the fallback language will be checked. - * If the fallback key isn't found in the fallback language either, the value returned will be "__NOT_FOUND__" - */ - fallback_key = "error.unknown"; - /** - * A list of languages that have been hydrated/updated post class initialisation - */ - hydrated_languages: string[] = []; - - /** - * Construct a new LangaugeService class with the given languages and fallbacks - * @param languages The languages to use - * @param fallback_language The fallback language to use - * @param fallback_key The fallback key to use - */ - constructor({ - languages = {}, - fallback_language = "en", - fallback_key = "error.unknown", - }: { - languages?: { - [key: string]: Record; - }; - fallback_language?: string; - fallback_key?: string; - } = {}) { - for (const key in languages) { - this.addLanguage(key, languages[key]); - } - - this.fallback_key = fallback_key; - this.fallback_language = fallback_language; - } - - /** - * Returns an array of all supported languages - * @returns Array of all the language codes supported - */ - allSupported(): string[] { - return Object.keys(this.languages); - } - - /** - * Add a new language into the languages object - * @param code The language code to add - * @param lang The language object to add - */ - addLanguage(code: string, lang: Record) { - this.languages[code] = flattenObject(lang); - } - - /** - * Merge a new set of translations and keys into the language object - * @param code The language code to use - * @returns The language object for the given language code - */ - hydrateLanguage(code: string, lang: Record) { - this.addLanguage(code, { ...this.languages[code], ...lang }); - this.setHydrated(code); - } - - /** - * Sets a language as hydrated - * @param code The language code to set as hydrated - */ - setHydrated(code: string) { - this.hydrated_languages = Array.from( - new Set([...this.hydrated_languages, code]), - ); - } - - /** - * Checks to see if a language is hydrated/updated - * @param code The language code to check - * @returns Whether the language is hydrated - */ - isHydrated(code: string) { - return this.hydrated_languages.includes(code); - } - - /** - * Checks if a language is supported - * @param code The language code to check - * @returns Whether the language is supported - */ - isSupported(code: string): boolean { - return !!this.languages[code]; - } - - /** - * Returns the value for a specific language and key - * @param lang_code The language code to use - * @param key The key to translate/use - * @returns The value of the translation/localisation key if it exists - */ - getKey(lang_code: string, key: string): string { - let lang = (lang_code || this.fallback_language) as string; - if (!this.isSupported(lang)) { - lang = this.fallback_language; - } - - // Get the value from the language object for further processing - const found = this.languages[lang]?.[key] || - this.languages[lang]?.[this.fallback_key] || - this.languages?.[this.fallback_language]?.[key] || - this.languages?.[this.fallback_language]?.[this.fallback_key] || - "__NOT_FOUND__"; - - return found; - } - - /** - * Translates a key to a specific language, replacing substrings with necessary values as needed - * @param key The key to translate/use - * @param opts Options for the translation (lang, data, etc.) - * @returns The translated string - */ - 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 || ({} as T), functions || {}); - } - - /** - * Returns a new function for translating to a specific language replacing the t method - * @param lang_code The language code to use - * @returns Function taking place of the t method - */ - getTranslationFunc(lang_code: string) { - return (key: string, opts: Record = {}) => { - return this.t(key, { lang: lang_code, ...opts }); - }; - } + /** + * A map of language codes and their given translations + */ + languages: { + [key: string]: Flattened; + } = {}; + /** + * The language code to fall back to if the language is not supported + */ + fallback_language = "en"; + /** + * A key to fall back to if the key is not found in the given language. + * If the fallback key is not found, the fallback language will be checked. + * If the fallback key isn't found in the fallback language either, the value returned will be "__NOT_FOUND__" + */ + fallback_key = "error.unknown"; + /** + * A list of languages that have been hydrated/updated post class initialisation + */ + hydrated_languages: string[] = []; + + /** + * Construct a new LangaugeService class with the given languages and fallbacks + * @param languages The languages to use + * @param fallback_language The fallback language to use + * @param fallback_key The fallback key to use + */ + constructor({ + languages = {}, + fallback_language = "en", + fallback_key = "error.unknown", + }: { + languages?: { + [key: string]: Record; + }; + fallback_language?: string; + fallback_key?: string; + } = {}) { + for (const key in languages) { + this.addLanguage(key, languages[key]); + } + + this.fallback_key = fallback_key; + this.fallback_language = fallback_language; + } + + /** + * Returns an array of all supported languages + * @returns Array of all the language codes supported + */ + allSupported(): string[] { + return Object.keys(this.languages); + } + + /** + * Add a new language into the languages object + * @param code The language code to add + * @param lang The language object to add + */ + addLanguage( + code: string, + lang: Record | Flattened, + flattened = false, + ) { + this.languages[code] = + flattened && flattened instanceof Flattened + ? Flattened.fromFlattened(lang as Flattened) + : Flattened.toFlattened(lang as Record); + } + + /** + * Merge a new set of translations and keys into the language object + * @param code The language code to use + * @returns The language object for the given language code + */ + hydrateLanguage(code: string, lang: Record | Flattened) { + let new_obj: Flattened; + if (lang instanceof Flattened) { + new_obj = lang; + } else { + new_obj = Flattened.toFlattened(lang); + } + this.addLanguage(code, this.languages[code].mergeFlattened(new_obj)); + this.setHydrated(code); + } + + /** + * Sets a language as hydrated + * @param code The language code to set as hydrated + */ + setHydrated(code: string) { + this.hydrated_languages = Array.from( + new Set([...this.hydrated_languages, code]), + ); + } + + /** + * Checks to see if a language is hydrated/updated + * @param code The language code to check + * @returns Whether the language is hydrated + */ + isHydrated(code: string) { + return this.hydrated_languages.includes(code); + } + + /** + * Checks if a language is supported + * @param code The language code to check + * @returns Whether the language is supported + */ + isSupported(code: string): boolean { + return !!this.languages[code]; + } + + /** + * Returns the value for a specific language and key + * @param lang_code The language code to use + * @param key The key to translate/use + * @returns The value of the translation/localisation key if it exists + */ + getKey(lang_code: string, key: string): string { + let lang = (lang_code || this.fallback_language) as string; + if (!this.isSupported(lang)) { + lang = this.fallback_language; + } + + // Get the value from the language object for further processing + return ( + this.languages[lang]?.getKey(key) || + this.languages[lang]?.getKey(this.fallback_key) || + this.languages[this.fallback_language]?.getKey(key) || + this.languages[this.fallback_language]?.getKey( + this.fallback_key, + ) || + "__NOT_FOUND__" + ); + } + + /** + * Translates a key to a specific language, replacing substrings with necessary values as needed + * @param key The key to translate/use + * @param opts Options for the translation (lang, data, etc.) + * @returns The translated string + */ + 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 || ({} as T), functions || {}); + } + + /** + * Returns a new function for translating to a specific language replacing the t method + * @param lang_code The language code to use + * @returns Function taking place of the t method + */ + getTranslationFunc(lang_code: string) { + return (key: string, opts: Record = {}) => { + return this.t(key, { lang: lang_code, ...opts }); + }; + } } export const parseString = format; diff --git a/scripts/build_npm.ts b/scripts/build_npm.ts index e28ebc8..ea5e566 100644 --- a/scripts/build_npm.ts +++ b/scripts/build_npm.ts @@ -6,28 +6,28 @@ const DIR_NAME = "npm_build"; await emptyDir(`./${DIR_NAME}`); await build({ - entryPoints: ["./mod.ts", "./demo.ts"], - outDir: `./${DIR_NAME}`, - shims: { - // see JS docs for overview and more options - deno: true, - }, - test: true, - declaration: true, - package: { - // package.json properties - name: "@locale-kit/locale-kit", - version: Deno.args[0], - description: "A i18n/l10n library from Deno to Node ❤️", - license: "MIT", - repository: { - type: "git", - url: "git+https://github.com/locale-kit/locale-kit.git", - }, - bugs: { - url: "https://github.com/locale-kit/locale-kit/issues", - }, - }, + entryPoints: ["./mod.ts", "./demo.ts"], + outDir: `./${DIR_NAME}`, + shims: { + // see JS docs for overview and more options + deno: true, + }, + test: true, + declaration: true, + package: { + // package.json properties + name: "@locale-kit/locale-kit", + version: Deno.args[0], + description: "A i18n/l10n library from Deno to Node ❤️", + license: "MIT", + repository: { + type: "git", + url: "git+https://github.com/locale-kit/locale-kit.git", + }, + bugs: { + url: "https://github.com/locale-kit/locale-kit/issues", + }, + }, }); // post build steps diff --git a/test/flatten.test.ts b/test/flatten.test.ts new file mode 100644 index 0000000..3ca5f96 --- /dev/null +++ b/test/flatten.test.ts @@ -0,0 +1,303 @@ +import { + assertEquals, + assertObjectMatch, +} from "https://deno.land/std@0.218.2/assert/mod.ts"; +import { Flattened } from "../util/flatten.ts"; + +Deno.test("toObject method returns the unflattened object", () => { + const flattened = Flattened.toFlattened({ + some: { nested: { property: "value" } }, + }); + // Add your test data here + // flattened.someProperty = someValue; + + const result = flattened.toObject(); + // Add your expected unflattened object here + const expected = { some: { nested: { property: "value" } } }; + + assertObjectMatch(result, expected); +}); + +Deno.test("toObject method returns the unflattened object", () => { + const flattened = Flattened.toFlattened({ + some: { nested: { property: "value" } }, + }); + // Add your test data here + // flattened.someProperty = someValue; + + const result = flattened.toObject(); + // Add your expected unflattened object here + const expected = { some: { nested: { property: "value" } } }; + + assertObjectMatch(result, expected); +}); + +Deno.test( + "toString method returns the string representation of the Flattened object", + () => { + const flattened = Flattened.toFlattened({ + some: { nested: { property: "value" } }, + }); + // Add your test data here + // flattened.someProperty = someValue; + + const result = flattened.toString(); + // Add your expected string representation here + const expected = JSON.stringify({ + __data: flattened.__data, + __map: flattened.__map, + }); + + assertEquals(result, expected); + }, +); + +Deno.test("getMappedKey method returns the mapped key", () => { + const flattened = Flattened.toFlattened({ + some: { nested: { property: "value" } }, + }); + // Add your test data here + const key = "some.nested.property"; + + const result = flattened.getMappedKey(key); + // Add your expected mapped key here + const expected = "some:o.nested:o.property:s"; + + assertEquals(result, expected); +}); + +Deno.test("getMappedKey method returns undefined for non-existent key", () => { + const flattened = Flattened.toFlattened({ + some: { nested: { property: "value" } }, + }); + // Add your test data here + const key = "nonExistentKey"; + + const result = flattened.getMappedKey(key); + // Add your expected result here + const expected = undefined; + + assertEquals(result, expected); +}); + +Deno.test("getKey method returns the value for the given key", () => { + const flattened = Flattened.toFlattened({ + some: { nested: { property: "value" } }, + }); + // Add your test data here + const key = "some.nested.property"; + + const result = flattened.getKey(key); + // Add your expected value here + const expected = "value"; + + assertEquals(result, expected); +}); + +Deno.test("getKey method returns undefined for non-existent key", () => { + const flattened = Flattened.toFlattened({ + some: { nested: { property: "value" } }, + }); + // Add your test data here + const key = "nonExistentKey"; + + const result = flattened.getKey(key); + // Add your expected result here + const expected = undefined; + + assertEquals(result, expected); +}); + +Deno.test("hasKey method returns true for existing key", () => { + const flattened = Flattened.toFlattened({ + some: { nested: { property: "value" } }, + }); + // Add your test data here + const key = "some.nested.property"; + + const result = flattened.hasKey(key); + // Add your expected result here + const expected = true; + + assertEquals(result, expected); +}); + +Deno.test("hasKey method returns false for non-existent key", () => { + const flattened = Flattened.toFlattened({ + some: { nested: { property: "value" } }, + }); + // Add your test data here + const key = "nonExistentKey"; + + const result = flattened.hasKey(key); + // Add your expected result here + const expected = false; + + assertEquals(result, expected); +}); + +Deno.test( + "mergeFlattened method merges the data and map of two Flattened objects", + () => { + const flattened1 = Flattened.toFlattened({ + some: { nested: { property: "value1" } }, + }); + const flattened2 = Flattened.toFlattened({ + another: { nested: { property: "value2" } }, + }); + + const result = flattened1.mergeFlattened(flattened2); + + const expectedData = { + "some:o.nested:o.property:s": "value1", + "another:o.nested:o.property:s": "value2", + }; + const expectedMap = { + "some.nested.property": "some:o.nested:o.property:s", + "another.nested.property": "another:o.nested:o.property:s", + }; + + assertObjectMatch(result.__data, expectedData); + assertObjectMatch(result.__map, expectedMap); + }, +); + +Deno.test( + "mergeObject method merges the given object and returns a Flattened object", + () => { + const flattened = Flattened.toFlattened({ + some: { nested: { property: "value" } }, + }); + flattened.mergeObject({ another: { nested: { property: "value" } } }); + + const expectedData = { + "some:o.nested:o.property:s": "value", + "another:o.nested:o.property:s": "value", + }; + const expectedMap = { + "some.nested.property": "some:o.nested:o.property:s", + "another.nested.property": "another:o.nested:o.property:s", + }; + + assertObjectMatch(flattened.__data, expectedData); + assertObjectMatch(flattened.__map, expectedMap); + }, +); + +// "toFlattened method returns a Flattened object" +Deno.test("toFlattened method returns a Flattened object", () => { + const obj = { + some: { + nested: { property: "value", array: [1, "a", 4n, { inside: "array" }] }, + }, + }; + const result = Flattened.toFlattened(obj); + const expectedData = { + "some:o.nested:o.property:s": "value", + "some:o.nested:o.array:a.0:i;n": 1, + "some:o.nested:o.array:a.1:i;s": "a", + "some:o.nested:o.array:a.2:i;bi": "4", + "some:o.nested:o.array:a.3:i;o.inside:s": "array", + }; + const expectedMap = { + "some.nested.property": "some:o.nested:o.property:s", + "some.nested.array.0": "some:o.nested:o.array:a.0:i;n", + "some.nested.array.1": "some:o.nested:o.array:a.1:i;s", + "some.nested.array.2": "some:o.nested:o.array:a.2:i;bi", + "some.nested.array.3.inside": "some:o.nested:o.array:a.3:i;o.inside:s", + }; + + assertObjectMatch(result.__data, expectedData); + assertObjectMatch(result.__map, expectedMap); +}); + +Deno.test( + "fromFlattened method creates a new Flattened object from a Flattened instance", + () => { + const flattenedData = { + "some:o.nested:o.property:s": "value", + "another:o.nested:o.property:s": "value", + }; + const flattenedMap = { + "some.nested.property": "some:o.nested:o.property:s", + "another.nested.property": "another:o.nested:o.property:s", + }; + const flattened = new Flattened({ + __data: flattenedData, + __map: flattenedMap, + }); + + const result = Flattened.fromFlattened(flattened); + + assertObjectMatch(result.__data, flattenedData); + assertObjectMatch(result.__map, flattenedMap); + }, +); + +Deno.test( + "fromFlattened method creates a new Flattened object from a FlattenedObject", + () => { + const flattenedObject = Flattened.toFlattened({ + some: { nested: { property: "value" } }, + }); + + const result = Flattened.fromFlattened(flattenedObject); + + assertObjectMatch(result.__data, flattenedObject.__data); + assertObjectMatch(result.__map, flattenedObject.__map); + }, +); + +// Test if the Flattened class detects that the map isn't the same length as the data and generates a new map +Deno.test( + "Flattened class generates a new map if the map isn't the same length as the data", + () => { + const flattened = new Flattened({ + __data: { + "some:o.nested:o.property:s": "value", + "another:o.nested:o.property:s": "value", + }, + __map: { + "some.nested.property": "some:o.nested:o.property:s", + }, + }); + + const expectedMap = { + "some.nested.property": "some:o.nested:o.property:s", + "another.nested.property": "another:o.nested:o.property:s", + }; + + assertObjectMatch(flattened.__map, expectedMap); + }, +); + +// Test if the Flattened class detects circular references +Deno.test( + "Flattened class detects and handles circular references correctly", + () => { + const obj: Record = { + some: { nested: { property: "value" } }, + }; + + // @ts-ignore + obj.some.nested.circular = obj.some; + + const flattened = Flattened.toFlattened(obj); + const unflattened = flattened.toObject(); + + const expected_data = { + "some:o.nested:o.property:s": "value", + "some:o.nested:o.circular:c": "some", + }; + const expected_map = { + "some.nested.property": "some:o.nested:o.property:s", + "some.nested.circular": "some:o.nested:o.circular:c", + }; + + assertObjectMatch(flattened.__data, expected_data); + assertObjectMatch(flattened.__map, expected_map); + assertObjectMatch(unflattened, obj); + // @ts-ignore + assertEquals(unflattened.some.nested.circular, unflattened.some); + }, +); diff --git a/test/format.test.ts b/test/format.test.ts new file mode 100644 index 0000000..c73d1e2 --- /dev/null +++ b/test/format.test.ts @@ -0,0 +1,116 @@ +import { assertEquals } from "https://deno.land/std@0.218.2/assert/mod.ts"; +import { FunctionType } from "../types/fn.ts"; +import { format } from "../util/format.ts"; + +Deno.test("Format with no data", () => { + const str = "Hello, {{name}}!"; + const result = format(str); + assertEquals(result, "Hello, [no_value]!"); +}); + +Deno.test("Format with data", () => { + const str = "Hello, {{name}}!"; + const data = { name: "John Doe" }; + const result = format(str, data); + assertEquals(result, "Hello, John Doe!"); +}); + +Deno.test("Format with nested data", () => { + const str = "Hello, {{user.name}}!"; + const data = { user: { name: "John Doe" } }; + const result = format(str, data); + assertEquals(result, "Hello, John Doe!"); +}); + +Deno.test("Format with fallback value", () => { + const str = "Hello, {{name}}||Guest||!"; + const data = { name: undefined }; + const result = format(str, data); + assertEquals(result, "Hello, Guest!"); +}); + +Deno.test("Format with conditional cases", () => { + const str = + "You have {{count}} [[~ {count} 1: `message` | default: `messages` ]]"; + const data1 = { count: 1 }; + const data2 = { count: 2 }; + const result1 = format(str, data1); + const result2 = format(str, data2); + assertEquals(result1, "You have 1 message"); + assertEquals(result2, "You have 2 messages"); +}); + +Deno.test("Format with custom functions", () => { + const str = + "You are [[~ {age} GTE(num:18): `an adult` | default: `a child` ]]"; + const data1 = { age: 18 }; + const data2 = { age: 17 }; + + const fns: Record> = { + GTE: (val: unknown, ctx: typeof data1) => (val as number) >= ctx.age, + }; + + const result1 = format(str, data1, fns); + const result2 = format(str, data2, fns); + assertEquals(result1, "You are an adult"); + assertEquals(result2, "You are a child"); +}); + +Deno.test("Format with empty string", () => { + const str = ""; + const result = format(str); + assertEquals(result, ""); +}); + +Deno.test("Format with no placeholders", () => { + const str = "Hello, world!"; + const result = format(str); + assertEquals(result, "Hello, world!"); +}); + +Deno.test("Format with missing data", () => { + const str = "Hello, {{name}}!"; + const result = format(str); + assertEquals(result, "Hello, [no_value]!"); +}); + +Deno.test("Format with missing nested data", () => { + const str = "Hello, {{user.name}}!"; + const data = { user: {} }; + const result = format(str, data); + assertEquals(result, "Hello, [no_value]!"); +}); + +Deno.test("Format with missing fallback value", () => { + const str = "Hello, {{name}}||Guest||!"; + const data = {}; + const result = format(str, data); + assertEquals(result, "Hello, Guest!"); +}); + +Deno.test("Format with invalid function", () => { + const str = + "You are [[~ {age} INVALID(num:18): `an adult` | default: `a child` ]]"; + const data = { age: 18 }; + const result = format(str, data); + assertEquals( + result, + "You are [[~ {age} INVALID(num:18): `an adult` | default: `a child` ]]", + ); +}); + +Deno.test("Format with custom function", () => { + const str = + "You are [[~ {age} GTE(num:18): `an adult` | default: `a child` ]]"; + const data1 = { age: 18 }; + const data2 = { age: 17 }; + + const fns: Record> = { + GTE: (val: unknown, ctx: typeof data1) => (val as number) >= ctx.age, + }; + + const result1 = format(str, data1, fns); + const result2 = format(str, data2, fns); + assertEquals(result1, "You are an adult"); + assertEquals(result2, "You are a child"); +}); diff --git a/test/function.test.ts b/test/function.test.ts new file mode 100644 index 0000000..cab4f21 --- /dev/null +++ b/test/function.test.ts @@ -0,0 +1,64 @@ +import { + assertEquals, + assertObjectMatch, +} from "https://deno.land/std@0.218.2/assert/mod.ts"; +import { getFunctionParameters } from "../util/function.ts"; + +Deno.test("getFunctionParameters with no arguments", () => { + const str = "Hello, world!"; + const result = getFunctionParameters(str); + assertEquals(result, []); +}); + +Deno.test("getFunctionParameters with multiple arguments", () => { + const fns = { + isAdult: (a: unknown) => console.log(a), + }; + + const test_a = "EQ(str:`John Doe`)"; + const test_b = "EQ(num:25)"; + const test_c = "EQ(bool:true)"; + const test_d = "EQ(key:{user.name})"; + const test_e = "EQ(str:`John Doe`, num:25)"; + const test_f = "EQ(str:`John Doe`, bool:true)"; + const test_g = "EQ(fn: { utils.isAdult })"; + const [result_a] = getFunctionParameters(test_a); + const [result_b] = getFunctionParameters(test_b); + const [result_c] = getFunctionParameters(test_c); + const [result_d] = getFunctionParameters(test_d, { + user: { name: "John Doe Keyed" }, + }); + const [result_e] = getFunctionParameters(test_e); + const [result_f] = getFunctionParameters(test_f); + const [result_g] = getFunctionParameters( + test_g, + {}, + { + utils: { + isAdult: (a: unknown) => console.log(a), + }, + }, + ); + + const e = (result: unknown): Record => + result as Record; + + assertObjectMatch(e(result_a), { val: "John Doe", type: "str" }); + assertObjectMatch(e(result_b), { val: 25, type: "num" }); + assertObjectMatch(e(result_c), { val: true, type: "bool" }); + assertObjectMatch(e(result_d), { val: "John Doe Keyed", type: "key" }); + assertObjectMatch(e(result_e), { val: "John Doe", type: "str" }); + assertObjectMatch(e(result_f), { val: "John Doe", type: "str" }); + // assertObjectMatch(e(result_g), { + // val: fns.isAdult, + // type: "fn", + // }); + assertEquals( + // deno-lint-ignore ban-unused-ignore + // deno-lint-ignore ban-types + // biome-ignore lint/complexity/noBannedTypes: + (e(result_g) as { val: Function }).val.toString(), + fns.isAdult.toString(), + ); + assertEquals(e(result_g).type, "fn"); +}); diff --git a/test/translation.bench.ts b/test/translation.bench.ts index 1f38f1c..eb75312 100755 --- a/test/translation.bench.ts +++ b/test/translation.bench.ts @@ -1,42 +1,41 @@ import { LocaleKit } from "../mod.ts"; const demo_language = { - common: { - "register": "Register", - "login": "Login", - "logout": "Logout", - "profile": "Profile", - "settings": "Settings", - "home": "Home", - "search": "Search", - "search_placeholder": "Search...", - }, - home: { - header: "Lorem Ipsum", - subtitle: "Donec mattis vehicula mi at dignissim", - welcome: - "Quisque efficitur cursus metus, at auctor odio luctus fermentum. In finibus dignissim dolor, vel faucibus lacus lacinia in. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; Vivamus diam quam, varius ac consequat non, dignissim non leo. Duis ante justo, venenatis id consectetur in, porttitor ac turpis. Integer eleifend sagittis posuere. Aliquam leo sapien, mattis ut sollicitudin quis, tempus vitae justo. Fusce feugiat et felis at cursus. Nullam felis ipsum, varius a augue sed, porta porta sem. Cras ultrices dui justo, eu pharetra nunc dictum id. Praesent et feugiat neque, in convallis sapien. Proin hendrerit ipsum in orci luctus sagittis sit amet sed neque. Etiam malesuada tortor quis lacus facilisis, ac ultricies libero efficitur. Phasellus tincidunt magna dolor, in tempor ex lacinia porta. Fusce tellus ipsum, tristique a elementum vitae, venenatis id mauris.", - // Populate common text parts of a home page including multiple paragraphs - // and a list of items - section_1: { - title: "Integer ut sem velit", - subtitle: "In fermentum consectetur imperdiet", - description: - "Sed eget magna nisi. Vivamus ultricies ex luctus venenatis dapibus. Maecenas vitae pretium nisl. Phasellus pharetra interdum nulla, quis varius felis sodales vitae. Integer at consectetur purus. Donec rutrum nisi at elit finibus, non consectetur nisl viverra. Quisque ac scelerisque nunc. Fusce purus odio, dictum eget tortor id, porta mattis elit. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Aliquam luctus viverra pulvinar. Praesent pretium mauris at tellus ornare sollicitudin. Cras vel pulvinar leo, eu semper justo. Nunc tempor posuere aliquet. Etiam molestie, nibh quis ultrices vestibulum, urna tortor placerat ex, vitae cursus dui eros sit amet tellus. Morbi nunc dui, rhoncus sit amet lorem quis, molestie scelerisque massa. Sed sit amet elit et quam pharetra scelerisque ultricies non dolor.", - }, - section_2: { - title: "Vivamus molestie", - subtitle: "Fusce id fringilla ante", - description: - "Ut vitae finibus augue. Mauris magna nibh, scelerisque ut lacus vitae, finibus aliquet lorem. Quisque finibus blandit luctus. Fusce laoreet sapien vel metus ultrices, eget porttitor augue feugiat. Proin rutrum dignissim congue. Donec volutpat faucibus commodo. Curabitur ultrices vitae libero sed finibus. Sed et eros velit. Nulla facilisi.", - }, - dynamic: { - "title": "Dynamic title", - "subtitle": "Dynamic subtitle", - "with_substitution": - "This is a demo showing the various dynamic replacement options: {{val}}", - "with_localisation_and_substitution": - `This is a demo showing the various dynamic replacement options: [[~ + common: { + register: "Register", + login: "Login", + logout: "Logout", + profile: "Profile", + settings: "Settings", + home: "Home", + search: "Search", + search_placeholder: "Search...", + }, + home: { + header: "Lorem Ipsum", + subtitle: "Donec mattis vehicula mi at dignissim", + welcome: + "Quisque efficitur cursus metus, at auctor odio luctus fermentum. In finibus dignissim dolor, vel faucibus lacus lacinia in. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; Vivamus diam quam, varius ac consequat non, dignissim non leo. Duis ante justo, venenatis id consectetur in, porttitor ac turpis. Integer eleifend sagittis posuere. Aliquam leo sapien, mattis ut sollicitudin quis, tempus vitae justo. Fusce feugiat et felis at cursus. Nullam felis ipsum, varius a augue sed, porta porta sem. Cras ultrices dui justo, eu pharetra nunc dictum id. Praesent et feugiat neque, in convallis sapien. Proin hendrerit ipsum in orci luctus sagittis sit amet sed neque. Etiam malesuada tortor quis lacus facilisis, ac ultricies libero efficitur. Phasellus tincidunt magna dolor, in tempor ex lacinia porta. Fusce tellus ipsum, tristique a elementum vitae, venenatis id mauris.", + // Populate common text parts of a home page including multiple paragraphs + // and a list of items + section_1: { + title: "Integer ut sem velit", + subtitle: "In fermentum consectetur imperdiet", + description: + "Sed eget magna nisi. Vivamus ultricies ex luctus venenatis dapibus. Maecenas vitae pretium nisl. Phasellus pharetra interdum nulla, quis varius felis sodales vitae. Integer at consectetur purus. Donec rutrum nisi at elit finibus, non consectetur nisl viverra. Quisque ac scelerisque nunc. Fusce purus odio, dictum eget tortor id, porta mattis elit. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Aliquam luctus viverra pulvinar. Praesent pretium mauris at tellus ornare sollicitudin. Cras vel pulvinar leo, eu semper justo. Nunc tempor posuere aliquet. Etiam molestie, nibh quis ultrices vestibulum, urna tortor placerat ex, vitae cursus dui eros sit amet tellus. Morbi nunc dui, rhoncus sit amet lorem quis, molestie scelerisque massa. Sed sit amet elit et quam pharetra scelerisque ultricies non dolor.", + }, + section_2: { + title: "Vivamus molestie", + subtitle: "Fusce id fringilla ante", + description: + "Ut vitae finibus augue. Mauris magna nibh, scelerisque ut lacus vitae, finibus aliquet lorem. Quisque finibus blandit luctus. Fusce laoreet sapien vel metus ultrices, eget porttitor augue feugiat. Proin rutrum dignissim congue. Donec volutpat faucibus commodo. Curabitur ultrices vitae libero sed finibus. Sed et eros velit. Nulla facilisi.", + }, + dynamic: { + title: "Dynamic title", + subtitle: "Dynamic subtitle", + with_substitution: + "This is a demo showing the various dynamic replacement options: {{val}}", + with_localisation_and_substitution: `This is a demo showing the various dynamic replacement options: [[~ {val} EQ(num: 0): ;:Equals zero {{val}}:; | LT(num: 0): ;:Less than zero {{val}}:; @@ -46,8 +45,7 @@ const demo_language = { | GT(num: 20): ;:Greater than 20 {{val}}:; | default: ;:Default {{val}}:; ]]`, - "with_localisation": - `This is a demo showing the various dynamic replacement options: [[~ + with_localisation: `This is a demo showing the various dynamic replacement options: [[~ {val} EQ(num: 0): ;:Equals zero:; | LT(num: 0): ;:Less than zero:; @@ -57,8 +55,8 @@ const demo_language = { | GT(num: 20): ;:Greater than 20:; | default: ;:Default:; ]]`, - }, - }, + }, + }, }; const demo_locale = new LocaleKit(); @@ -67,56 +65,56 @@ demo_locale.addLanguage("en", demo_language); // Add a language Deno.bench({ - name: "Add language", - fn: () => { - const locale = new LocaleKit(); + name: "Add language", + fn: () => { + const locale = new LocaleKit(); - locale.addLanguage("en", demo_language); - }, + locale.addLanguage("en", demo_language); + }, }); // Get a specific key Deno.bench({ - name: "Get a specific key", - fn: () => { - demo_locale.getKey("en", "home.header"); - }, + name: "Get a specific key", + fn: () => { + demo_locale.getKey("en", "home.header"); + }, }); // Translate a key Deno.bench({ - name: "Translate a key", - fn: () => { - demo_locale.t("home.header"); - }, + name: "Translate a key", + fn: () => { + demo_locale.t("home.header"); + }, }); // Translate a key with a substitution Deno.bench({ - name: "Translate a key with a substitution", - fn: () => { - demo_locale.t("home.dynamic.with_substitution", { val: 5 }); - }, + name: "Translate a key with a substitution", + fn: () => { + demo_locale.t("home.dynamic.with_substitution", { val: 5 }); + }, }); // Translate a key with a substitution and localisation Deno.bench({ - name: "Translate a key with a substitution and localisation", - fn: () => { - demo_locale.t("home.dynamic.with_localisation_and_substitution", { - val: 5, - arr: [6, 7, 8, 9, 10], - }); - }, + name: "Translate a key with a substitution and localisation", + fn: () => { + demo_locale.t("home.dynamic.with_localisation_and_substitution", { + val: 5, + arr: [6, 7, 8, 9, 10], + }); + }, }); // Translate a key with localisation Deno.bench({ - name: "Translate a key with localisation", - fn: () => { - demo_locale.t("home.dynamic.with_localisation", { - val: 5, - arr: [6, 7, 8, 9, 10], - }); - }, + name: "Translate a key with localisation", + fn: () => { + demo_locale.t("home.dynamic.with_localisation", { + val: 5, + arr: [6, 7, 8, 9, 10], + }); + }, }); diff --git a/test/translation.test.ts b/test/translation.test.ts index 1354ca5..74df4e0 100644 --- a/test/translation.test.ts +++ b/test/translation.test.ts @@ -1,577 +1,607 @@ import { - assertEquals, - assertObjectMatch, -} from "https://deno.land/std@0.160.0/testing/asserts.ts"; + assertEquals, + assertObjectMatch, +} from "https://deno.land/std@0.218.2/assert/mod.ts"; import { LocaleKit } from "../mod.ts"; import { FunctionType } from "../types/fn.ts"; Deno.test("Init with language", () => { - const svc = new LocaleKit({ - languages: { - en: { - test: { key: "Test Key" }, - }, - }, - }); - - assertObjectMatch(svc.languages, { - en: { - "test.key": "Test Key", - }, - }); + const svc = new LocaleKit({ + languages: { + en: { + test: { key: "Test Key" }, + }, + }, + }); + + assertObjectMatch(svc.languages, { + en: { + __data: { + "test:o.key:s": "Test Key", + }, + __map: { + "test.key": "test:o.key:s", + }, + }, + }); }); Deno.test("Add empty language", () => { - const svc = new LocaleKit(); + const svc = new LocaleKit(); - svc.addLanguage("en", {}); + svc.addLanguage("en", {}); - assertObjectMatch(svc.languages, { en: {} }); + assertObjectMatch(svc.languages, { en: {} }); }); Deno.test("Add language with translations", () => { - const svc = new LocaleKit(); - - svc.addLanguage("en", { - "test.key": "Test Key", - "test.key2": "Test Key 2", - }); - - assertObjectMatch(svc.languages, { - en: { - "test.key": "Test Key", - "test.key2": "Test Key 2", - }, - }); + const svc = new LocaleKit(); + + svc.addLanguage("en", { + "test.key": "Test Key", + "test.key2": "Test Key 2", + }); + + assertObjectMatch(svc.languages, { + en: { + __data: { + "test%2Ekey:s": "Test Key", + "test%2Ekey2:s": "Test Key 2", + }, + __map: { + "test.key": "test%2Ekey:s", + "test.key2": "test%2Ekey2:s", + }, + }, + }); }); Deno.test("Add language with translations and nested objects", () => { - const svc = new LocaleKit(); - - svc.addLanguage("en", { - common: { - navigation: { - title: "Test title", - }, - }, - }); - - assertObjectMatch(svc.languages, { - en: { - "common.navigation.title": "Test title", - }, - }); + const svc = new LocaleKit(); + + svc.addLanguage("en", { + common: { + navigation: { + title: "Test title", + }, + }, + }); + + assertObjectMatch(svc.languages, { + en: { + __data: { + "common:o.navigation:o.title:s": "Test title", + }, + __map: { + "common.navigation.title": "common:o.navigation:o.title:s", + }, + }, + }); }); Deno.test( - "Add language with translations and nested objects and arrays", - () => { - const svc = new LocaleKit(); - - svc.addLanguage("en", { - common: { - navigation: { - title: "Test title", - items: [ - { - title: "Test item 1", - }, - { - title: "Test item 2", - }, - ], - }, - }, - }); - - assertObjectMatch(svc.languages, { - en: { - "common.navigation.title": "Test title", - "common.navigation.items.0.title": "Test item 1", - "common.navigation.items.1.title": "Test item 2", - }, - }); - }, + "Add language with translations and nested objects and arrays", + () => { + const svc = new LocaleKit(); + + svc.addLanguage("en", { + common: { + navigation: { + title: "Test title", + items: [ + { + title: "Test item 1", + }, + { + title: "Test item 2", + }, + ], + }, + }, + }); + + assertObjectMatch(svc.languages, { + en: { + __data: { + "common:o.navigation:o.title:s": "Test title", + "common:o.navigation:o.items:a.0:i;o.title:s": "Test item 1", + "common:o.navigation:o.items:a.1:i;o.title:s": "Test item 2", + }, + __map: { + "common.navigation.title": "common:o.navigation:o.title:s", + "common.navigation.items.0.title": + "common:o.navigation:o.items:a.0:i;o.title:s", + "common.navigation.items.1.title": + "common:o.navigation:o.items:a.1:i;o.title:s", + }, + }, + }); + }, ); Deno.test( - "Add language with translations and nested objects and arrays and nested arrays", - () => { - const svc = new LocaleKit(); - - svc.addLanguage("en", { - common: { - navigation: { - title: "Test title", - items: [ - { - title: "Test item 1", - items: [ - { - title: "Test item 1.1", - }, - { - title: "Test item 1.2", - }, - ], - }, - { - title: "Test item 2", - }, - ], - }, - }, - }); - - assertObjectMatch(svc.languages, { - en: { - "common.navigation.title": "Test title", - "common.navigation.items.0.title": "Test item 1", - "common.navigation.items.0.items.0.title": "Test item 1.1", - "common.navigation.items.0.items.1.title": "Test item 1.2", - "common.navigation.items.1.title": "Test item 2", - }, - }); - }, + "Add language with translations and nested objects and arrays and nested arrays", + () => { + const svc = new LocaleKit(); + + svc.addLanguage("en", { + common: { + navigation: { + title: "Test title", + items: [ + { + title: "Test item 1", + items: [ + { + title: "Test item 1.1", + }, + { + title: "Test item 1.2", + }, + ], + }, + { + title: "Test item 2", + }, + ], + }, + }, + }); + + assertObjectMatch(svc.languages, { + en: { + __data: { + "common:o.navigation:o.title:s": "Test title", + "common:o.navigation:o.items:a.0:i;o.title:s": "Test item 1", + "common:o.navigation:o.items:a.0:i;o.items:a.0:i;o.title:s": + "Test item 1.1", + "common:o.navigation:o.items:a.0:i;o.items:a.1:i;o.title:s": + "Test item 1.2", + "common:o.navigation:o.items:a.1:i;o.title:s": "Test item 2", + }, + __map: { + "common.navigation.title": "common:o.navigation:o.title:s", + "common.navigation.items.0.title": + "common:o.navigation:o.items:a.0:i;o.title:s", + "common.navigation.items.0.items.0.title": + "common:o.navigation:o.items:a.0:i;o.items:a.0:i;o.title:s", + "common.navigation.items.0.items.1.title": + "common:o.navigation:o.items:a.0:i;o.items:a.1:i;o.title:s", + "common.navigation.items.1.title": + "common:o.navigation:o.items:a.1:i;o.title:s", + }, + }, + }); + }, ); Deno.test("Translate a key with no added data", () => { - const svc = new LocaleKit(); + const svc = new LocaleKit(); - svc.addLanguage("en", { - common: { - test: "Test string", - }, - }); + svc.addLanguage("en", { + common: { + test: "Test string", + }, + }); - assertEquals(svc.t("common.test"), "Test string"); + assertEquals(svc.t("common.test"), "Test string"); }); Deno.test("Translate a key with added data", () => { - const svc = new LocaleKit(); + const svc = new LocaleKit(); - svc.addLanguage("en", { - common: { - test: "Hello {{name}}", - }, - }); + svc.addLanguage("en", { + common: { + test: "Hello {{name}}", + }, + }); - assertEquals(svc.t("common.test", { name: "John Doe" }), "Hello John Doe"); + assertEquals(svc.t("common.test", { name: "John Doe" }), "Hello John Doe"); }); Deno.test("Translate a key with added data and nested objects", () => { - const svc = new LocaleKit(); - - svc.addLanguage("en", { - common: { - test: "Hello {{user.name}}", - }, - }); - - assertEquals( - svc.t("common.test", { user: { name: "John Doe" } }), - "Hello John Doe", - ); + const svc = new LocaleKit(); + + svc.addLanguage("en", { + common: { + test: "Hello {{user.name}}", + }, + }); + + assertEquals( + svc.t("common.test", { user: { name: "John Doe" } }), + "Hello John Doe", + ); }); Deno.test( - "Translate a key with added data and nested objects and arrays", - () => { - const svc = new LocaleKit(); - - svc.addLanguage("en", { - common: { - test: "Hello {{user.name}} {{user.addresses.0.city}}", - }, - }); - - assertEquals( - svc.t("common.test", { - user: { name: "John Doe", addresses: [{ city: "London" }] }, - }), - "Hello John Doe London", - ); - }, + "Translate a key with added data and nested objects and arrays", + () => { + const svc = new LocaleKit(); + + svc.addLanguage("en", { + common: { + test: "Hello {{user.name}} {{user.addresses.0.city}}", + }, + }); + + assertEquals( + svc.t("common.test", { + user: { name: "John Doe", addresses: [{ city: "London" }] }, + }), + "Hello John Doe London", + ); + }, ); Deno.test( - { - name: "Translate a conditional key", - }, - () => { - const svc = new LocaleKit(); - - svc.addLanguage("en", { - common: { - message_count: - "You have {{count}} [[~ {count} 1: `message` | default: `messages` ]]", - }, - }); - - assertEquals( - svc.t("common.message_count", { count: 1 }), - "You have 1 message", - ); - assertEquals( - svc.t("common.message_count", { count: 2 }), - "You have 2 messages", - ); - assertEquals( - svc.t("common.message_count", { count: 3 }), - "You have 3 messages", - ); - assertEquals( - svc.t("common.message_count", { count: 0 }), - "You have 0 messages", - ); - }, + { + name: "Translate a conditional key", + }, + () => { + const svc = new LocaleKit(); + + svc.addLanguage("en", { + common: { + message_count: + "You have {{count}} [[~ {count} 1: `message` | default: `messages` ]]", + }, + }); + + assertEquals( + svc.t("common.message_count", { count: 1 }), + "You have 1 message", + ); + assertEquals( + svc.t("common.message_count", { count: 2 }), + "You have 2 messages", + ); + assertEquals( + svc.t("common.message_count", { count: 3 }), + "You have 3 messages", + ); + assertEquals( + svc.t("common.message_count", { count: 0 }), + "You have 0 messages", + ); + }, ); Deno.test( - { - name: "Translate a conditional key with the semicolon-colon syntax", - }, - () => { - const svc = new LocaleKit(); - - svc.addLanguage("en", { - common: { - message_count: - "You have {{count}} [[~ {count} 1: ;:message:; | default: ;:messages:; ]]", - }, - }); - - assertEquals( - svc.t("common.message_count", { count: 1 }), - "You have 1 message", - ); - assertEquals( - svc.t("common.message_count", { count: 2 }), - "You have 2 messages", - ); - assertEquals( - svc.t("common.message_count", { count: 3 }), - "You have 3 messages", - ); - assertEquals( - svc.t("common.message_count", { count: 0 }), - "You have 0 messages", - ); - }, + { + name: "Translate a conditional key with the semicolon-colon syntax", + }, + () => { + const svc = new LocaleKit(); + + svc.addLanguage("en", { + common: { + message_count: + "You have {{count}} [[~ {count} 1: ;:message:; | default: ;:messages:; ]]", + }, + }); + + assertEquals( + svc.t("common.message_count", { count: 1 }), + "You have 1 message", + ); + assertEquals( + svc.t("common.message_count", { count: 2 }), + "You have 2 messages", + ); + assertEquals( + svc.t("common.message_count", { count: 3 }), + "You have 3 messages", + ); + assertEquals( + svc.t("common.message_count", { count: 0 }), + "You have 0 messages", + ); + }, ); Deno.test( - { - name: - "Print [fallback_key_missing] if default not found and fallback required", - }, - () => { - const svc = new LocaleKit(); - - svc.addLanguage("en", { - common: { - message_count: "You have {{count}} [[~ {count} 1: `message` ]]", - }, - }); - - assertEquals( - svc.t("common.message_count", { count: 1 }), - "You have 1 message", - ); - assertEquals( - svc.t("common.message_count", { count: 2 }), - "You have 2 [fallback_key_missing]", - ); - }, + { + name: "Print [fallback_key_missing] if default not found and fallback required", + }, + () => { + const svc = new LocaleKit(); + + svc.addLanguage("en", { + common: { + message_count: "You have {{count}} [[~ {count} 1: `message` ]]", + }, + }); + + assertEquals( + svc.t("common.message_count", { count: 1 }), + "You have 1 message", + ); + assertEquals( + svc.t("common.message_count", { count: 2 }), + "You have 2 [fallback_key_missing]", + ); + }, ); Deno.test("Nest data inside translation formatting", () => { - const svc = new LocaleKit(); - - svc.addLanguage("en", { - common: { - message_count: - "You have [[~ {count} 1: `only 1 message` | default: `{{count}} messages` ]]", - }, - }); - - assertEquals( - svc.t("common.message_count", { count: 1 }), - "You have only 1 message", - ); - assertEquals( - svc.t("common.message_count", { count: 2 }), - "You have 2 messages", - ); - assertEquals( - svc.t("common.message_count", { count: 3 }), - "You have 3 messages", - ); - assertEquals( - svc.t("common.message_count", { count: 0 }), - "You have 0 messages", - ); + const svc = new LocaleKit(); + + svc.addLanguage("en", { + common: { + message_count: + "You have [[~ {count} 1: `only 1 message` | default: `{{count}} messages` ]]", + }, + }); + + assertEquals( + svc.t("common.message_count", { count: 1 }), + "You have only 1 message", + ); + assertEquals( + svc.t("common.message_count", { count: 2 }), + "You have 2 messages", + ); + assertEquals( + svc.t("common.message_count", { count: 3 }), + "You have 3 messages", + ); + assertEquals( + svc.t("common.message_count", { count: 0 }), + "You have 0 messages", + ); }); Deno.test( - { - name: - "Translate a key with added data and fallback a nested value if missing", - }, - () => { - const svc = new LocaleKit(); - - svc.addLanguage("en", { - common: { - test: "Hello {{user.name}}||[john_doe]||", - }, - }); - - assertEquals( - svc.t("common.test", { user: { name: "John Doe", age: 20 } }), - "Hello John Doe", - ); - - assertEquals( - svc.t("common.test", { user: { age: 20 } }), - "Hello [john_doe]", - ); - }, + { + name: "Translate a key with added data and fallback a nested value if missing", + }, + () => { + const svc = new LocaleKit(); + + svc.addLanguage("en", { + common: { + test: "Hello {{user.name}}||[john_doe]||", + }, + }); + + assertEquals( + svc.t("common.test", { user: { name: "John Doe", age: 20 } }), + "Hello John Doe", + ); + + assertEquals( + svc.t("common.test", { user: { age: 20 } }), + "Hello [john_doe]", + ); + }, ); Deno.test( - "Fallback to default language and default key if key not found on language", - () => { - const svc = new LocaleKit(); - - svc.addLanguage("en", { - common: { - test: "Hello {{name}}", - test2: "Hello {{name}} (test 2 en)", - translation_error: "Translation error", - }, - error: { - unknown: "Unknown error", - }, - }); - - svc.addLanguage("fr", { - common: { - test: "Bonjour {{name}}", - }, - }); - - assertEquals( - svc.t("common.test", { name: "John Doe", lang: "en" }), - "Hello John Doe", - ); - assertEquals( - svc.t("common.test", { name: "John Doe", lang: "fr" }), - "Bonjour John Doe", - ); - assertEquals( - svc.t("common.test", { name: "John Doe", lang: "es" }), - "Hello John Doe", - ); - assertEquals(svc.t("common.test", { name: "John Doe" }), "Hello John Doe"); - assertEquals( - svc.t("common.test2", { name: "John Doe", lang: "fr" }), - "Hello John Doe (test 2 en)", - ); - assertEquals( - svc.t("common.test3", { name: "John Doe", lang: "es" }), - "Unknown error", - ); - }, + "Fallback to default language and default key if key not found on language", + () => { + const svc = new LocaleKit(); + + svc.addLanguage("en", { + common: { + test: "Hello {{name}}", + test2: "Hello {{name}} (test 2 en)", + translation_error: "Translation error", + }, + error: { + unknown: "Unknown error", + }, + }); + + svc.addLanguage("fr", { + common: { + test: "Bonjour {{name}}", + }, + }); + + assertEquals( + svc.t("common.test", { name: "John Doe", lang: "en" }), + "Hello John Doe", + ); + assertEquals( + svc.t("common.test", { name: "John Doe", lang: "fr" }), + "Bonjour John Doe", + ); + assertEquals( + svc.t("common.test", { name: "John Doe", lang: "es" }), + "Hello John Doe", + ); + assertEquals(svc.t("common.test", { name: "John Doe" }), "Hello John Doe"); + assertEquals( + svc.t("common.test2", { name: "John Doe", lang: "fr" }), + "Hello John Doe (test 2 en)", + ); + assertEquals( + svc.t("common.test3", { name: "John Doe", lang: "es" }), + "Unknown error", + ); + }, ); // A test for when a GT() case is found in a translation Deno.test( - { name: "Translate a key with added data and nested objects" }, - () => { - const svc = new LocaleKit(); - - svc.addLanguage("en", { - common: { - test: - "You are [[~ {age} GTE(num:18): `an adult` | default: `a child` ]]", - }, - }); - - assertEquals(svc.t("common.test", { age: 18 }), "You are an adult"); - assertEquals(svc.t("common.test", { age: 17 }), "You are a child"); - }, + { name: "Translate a key with added data and nested objects" }, + () => { + const svc = new LocaleKit(); + + svc.addLanguage("en", { + common: { + test: "You are [[~ {age} GTE(num:18): `an adult` | default: `a child` ]]", + }, + }); + + assertEquals(svc.t("common.test", { age: 18 }), "You are an adult"); + assertEquals(svc.t("common.test", { age: 17 }), "You are a child"); + }, ); Deno.test( - { - name: - "Translate a key with added data and nested objects (this time with array lengths)", - }, - () => { - const svc = new LocaleKit(); - - svc.addLanguage("en", { - common: { - test: - "You have [[~ {friends} LEN_GTE(num:3): `some` | default: `a couple` ]] friends", - }, - }); - - assertEquals( - svc.t("common.test", { friends: ["goerge", "jeff", "amy"] }), - "You have some friends", - ); - assertEquals( - svc.t("common.test", { friends: ["goerge", "jeff"] }), - "You have a couple friends", - ); - }, + { + name: "Translate a key with added data and nested objects (this time with array lengths)", + }, + () => { + const svc = new LocaleKit(); + + svc.addLanguage("en", { + common: { + test: "You have [[~ {friends} LEN_GTE(num:3): `some` | default: `a couple` ]] friends", + }, + }); + + assertEquals( + svc.t("common.test", { friends: ["goerge", "jeff", "amy"] }), + "You have some friends", + ); + assertEquals( + svc.t("common.test", { friends: ["goerge", "jeff"] }), + "You have a couple friends", + ); + }, ); Deno.test( - { - name: - "Translate a key with added data and nested objects (this time with string lengths)", - }, - () => { - const svc = new LocaleKit(); - - svc.addLanguage("en", { - common: { - test: - "This is a [[~ {str} LEN_GTE(num:12): `longer` | default: `shorter` ]] string", - }, - }); - - assertEquals( - svc.t("common.test", { str: "Hello World!" }), - "This is a longer string", - ); - assertEquals( - svc.t("common.test", { str: "Hello!" }), - "This is a shorter string", - ); - }, + { + name: "Translate a key with added data and nested objects (this time with string lengths)", + }, + () => { + const svc = new LocaleKit(); + + svc.addLanguage("en", { + common: { + test: "This is a [[~ {str} LEN_GTE(num:12): `longer` | default: `shorter` ]] string", + }, + }); + + assertEquals( + svc.t("common.test", { str: "Hello World!" }), + "This is a longer string", + ); + assertEquals( + svc.t("common.test", { str: "Hello!" }), + "This is a shorter string", + ); + }, ); Deno.test( - { - name: - "Translate a key with added data and nested objects (this time with object lengths)", - }, - () => { - const svc = new LocaleKit(); - - svc.addLanguage("en", { - common: { - test: - "This is a [[~ {obj} LEN_GTE(num:3): `longer` | default: `shorter` ]] object", - }, - }); - - assertEquals( - svc.t("common.test", { obj: { key_1: true, key_2: true, key_3: true } }), - "This is a longer object", - ); - assertEquals( - svc.t("common.test", { obj: { key_1: true, key_2: true } }), - "This is a shorter object", - ); - }, + { + name: "Translate a key with added data and nested objects (this time with object lengths)", + }, + () => { + const svc = new LocaleKit(); + + svc.addLanguage("en", { + common: { + test: "This is a [[~ {obj} LEN_GTE(num:3): `longer` | default: `shorter` ]] object", + }, + }); + + assertEquals( + svc.t("common.test", { obj: { key_1: true, key_2: true, key_3: true } }), + "This is a longer object", + ); + assertEquals( + svc.t("common.test", { obj: { key_1: true, key_2: true } }), + "This is a shorter object", + ); + }, ); Deno.test({ name: "Translate a key and handle function calls" }, () => { - const svc = new LocaleKit(); - - svc.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_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_age", { age: 18 }), "You are an adult"); - assertEquals(svc.t("common.test_age", { age: 13 }), "You are a teenager"); - assertEquals(svc.t("common.test_age", { age: 8 }), "You are a child"); - - 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", - ); + const svc = new LocaleKit(); + + svc.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_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_age", { age: 18 }), "You are an adult"); + assertEquals(svc.t("common.test_age", { age: 13 }), "You are a teenager"); + assertEquals(svc.t("common.test_age", { age: 8 }), "You are a child"); + + 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", + ); }); // 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", - ); - }, + { + 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 index 38867e9..b09cf1b 100644 --- a/types/fn.ts +++ b/types/fn.ts @@ -1,7 +1,3 @@ export type FunctionType< - T extends Record = Record, -> = ( - val: unknown, - ctx: T, - matched: string, -) => unknown; + T extends Record = Record, +> = (val: unknown, ctx: T, matched: string) => unknown; diff --git a/types/format.ts b/types/format.ts index 34ac79b..278ad3b 100644 --- a/types/format.ts +++ b/types/format.ts @@ -1,3 +1,4 @@ +// deno-lint-ignore-file no-explicit-any export type ArgType = number | string; export type FuncParamType = "num" | "str" | "key" | "bool" | "fn"; @@ -6,13 +7,10 @@ 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; - }; + [func_name: string]: { + arg_count: number; + arg_types: [FuncParamType[], FuncParamType[]?]; + // biome-ignore lint/suspicious/noExplicitAny: + func: (...args: any[]) => boolean; + }; } diff --git a/util/flatten.ts b/util/flatten.ts new file mode 100644 index 0000000..3004abf --- /dev/null +++ b/util/flatten.ts @@ -0,0 +1,603 @@ +import { getNestedKeyValue } from "./obj.ts"; + +const isString = (value: unknown): boolean => typeof value === "string"; +const isBoolean = (value: unknown): boolean => typeof value === "boolean"; +const isNumber = (value: unknown): boolean => typeof value === "number"; +const isUndefined = (value: unknown): boolean => typeof value === "undefined"; +const isBigInt = (value: unknown): boolean => typeof value === "bigint"; +const isArray = (value: unknown): boolean => Array.isArray(value); +const isNull = (value: unknown): boolean => value === null; +const isObject = (value: unknown): boolean => + Object.prototype.toString.call(value) === "[object Object]"; + +type FlattenedObject = { + __map: Record; + __data: Record; +} & Record; +type ValueType = + | "string" + | "boolean" + | "number" + | "undefined" + | "bigint" + | "array" + | "null" + | "object" + | "index" + | "circular"; + +export class Flattened { + __data: Record; + __map: Record; + + constructor( + flattened: FlattenedObject | Flattened = { __data: {}, __map: {} }, + ) { + this.__data = flattened.__data; + + if (Object.keys(flattened.__map) !== Object.keys(flattened.__data)) { + this.__map = Flattened.utils.generateMap(flattened.__data); + } else { + this.__map = flattened.__map; + } + } + + /** + * Converts an object into a flattened representation. + * @param obj - The object to be flattened. + * @returns The flattened representation of the object. + */ + static toFlattened(obj: Record): Flattened { + return Flattened.flatten.flattenObject({ to_flatten: obj }); + } + + /** + * Creates a new Flattened instance from a flattened object. + * + * @param flattened The flattened object to create the Flattened instance from. + * @returns A new Flattened instance. + */ + static fromFlattened(flattened: Flattened | FlattenedObject): Flattened { + return new Flattened(flattened); + } + + /** + * Converts the Flattened object to a nested object that should match the original object. + * @returns The nested object representation of the Flattened object. + */ + toObject>(): T { + return Flattened.unflatten.unflattenObject({ flattened: this }) as T; + } + + /** + * Retrieves the mapped value for the given key. + * + * @param key - The key to retrieve the mapped value for. + * @returns The mapped value for the given key. + */ + getMappedKey(key: string): string { + return this.__map[key]; + } + + /** + * Retrieves the value associated with the specified key from the data object. + * + * @template T - The type of the value to retrieve. + * @param key - The key of the value to retrieve. + * @returns The value associated with the specified key. + */ + getKey(key: string): T { + return this.__data[this.getMappedKey(key)] as T; + } + + /** + * Checks if the specified key exists in the flattened object. + * @param key - The key to check. + * @returns `true` if the key exists, `false` otherwise. + */ + hasKey(key: string): boolean { + return this.getMappedKey(key) !== undefined; + } + + /** + * Merges the provided `flattened` object into the current `Flattened` object. + * + * @param flattened - The `Flattened` object to merge. + * @returns The merged `Flattened` object. + */ + mergeFlattened(flattened: Flattened): Flattened { + this.__data = { ...this.__data, ...flattened.__data }; + this.__map = { ...this.__map, ...flattened.__map }; + + return this; + } + + /** + * Merges the provided object into a flattened structure. + * + * @param obj - The object to be flattened. + * @returns The flattened object. + */ + mergeObject(obj: Record): Flattened { + const flattened = Flattened.flatten.flattenObject({ to_flatten: obj }); + + return this.mergeFlattened(flattened); + } + + /** + * Returns a string representation of the object. + * @returns A string representation of the object. + */ + toString(): string { + return JSON.stringify({ __data: this.__data, __map: this.__map }); + } + + /** + * A mapping of value types to their corresponding postfix strings. + */ + static key_postfix = { + string: "s", + boolean: "b", + number: "n", + undefined: "u", + array: "a", + object: "o", + index: "i", + circular: "c", + bigint: "bi", + null: "nu", + } as { [key in ValueType]: string }; + /** + * Mapping of key postfixes to their corresponding value types. + */ + static key_postfix_rev = { + s: "string", + b: "boolean", + n: "number", + u: "undefined", + a: "array", + o: "object", + i: "index", + c: "circular", + bi: "bigint", + nu: "null", + } as { [key: string]: ValueType }; + + private static utils = { + generateMap(data: Record): Record { + const map: Record = {}; + + for (const [key, _value] of Object.entries(data)) { + const decoded_key = key + .split(".") + .map((part) => Flattened.unflatten.decodeKeyPart(part).key) + .join("."); + + map[decoded_key] = key; + } + + return map; + }, + }; + + /** + * Utility class for flattening objects. + */ + private static flatten = { + /** + * Encodes a key part and appends the value type postfix. + * @param part The key part to encode. + * @param val_type The value type. + * @returns The encoded key part with the value type postfix. + */ + encodeKeyPart( + part: string, + val_type: ValueType, + secondary_val_type?: ValueType, + ): string { + const key = encodeURIComponent(part).replaceAll(".", "%2E"); + + return `${key}:${Flattened.key_postfix[val_type]}${ + secondary_val_type + ? `;${Flattened.key_postfix[secondary_val_type]}` + : "" + }`; + }, + /** + * Retrieves the type information for a given key-value pair. + * + * @param key - The key of the pair. + * @param value - The value of the pair. + * @param manual_value_type - (Optional) A manually specified value type. + * @returns An object containing the key, raw_key, value_type, and invalid flag. + */ + getType( + key: string, + value: unknown, + manual_value_type?: ValueType, + ): { + key: string; + raw_key: string; + value_type: ValueType; + invalid: boolean; + } { + let key_part: string; + let value_type: ValueType; + + switch (true) { + case manual_value_type === "index": { + const { value_type: new_value_type } = this.getType(key, value); + + value_type = new_value_type; + key_part = this.encodeKeyPart(key, manual_value_type, value_type); + break; + } + case isString(value): + value_type = "string"; + key_part = this.encodeKeyPart(key, value_type); + break; + case isBoolean(value): + value_type = "boolean"; + key_part = this.encodeKeyPart(key, value_type); + break; + case isNumber(value): + value_type = "number"; + key_part = this.encodeKeyPart(key, value_type); + break; + case isUndefined(value): + value_type = "undefined"; + key_part = this.encodeKeyPart(key, value_type); + break; + case isBigInt(value): + value_type = "bigint"; + key_part = this.encodeKeyPart(key, value_type); + break; + case isArray(value): + value_type = "array"; + key_part = this.encodeKeyPart(key, value_type); + break; + case isNull(value): + value_type = "null"; + key_part = this.encodeKeyPart(key, value_type); + break; + case isObject(value): + value_type = "object"; + key_part = this.encodeKeyPart(key, value_type); + break; + default: + return { + invalid: true, + key: "", + value_type: "undefined", + raw_key: "", + }; + } + + return { + key: key_part, + value_type, + raw_key: key, + invalid: false, + }; + }, + /** + * Sets a property in the FlattenedObject. + * + * @param obj - The FlattenedObject to set the property in. + * @param key - The key of the property. + * @param value - The value of the property. + * @param value_type - The type of the value. + * @param raw_key - The raw key of the property. + */ + setProperty({ + obj, + key, + value, + value_type, + raw_key, + visited, + }: { + obj: Flattened; + key: string; + value: unknown; + value_type: ValueType; + raw_key: string; + visited?: Map; + }): void { + const data = obj.__data; + const map = obj.__map; + + switch (value_type) { + case "string": + case "boolean": + case "number": + case "circular": + data[key] = value; + map[raw_key] = key; + break; + case "undefined": + case "null": + data[key] = 0; + map[raw_key] = key; + break; + case "bigint": { + data[key] = (value).toString(); + map[raw_key] = key; + break; + } + case "array": { + (value as unknown[]).forEach((el, i) => { + const { + key: new_key, + value_type: new_value_type, + raw_key: new_raw_key, + } = this.getType(i.toString(), el, "index"); + if (!new_key || !new_value_type) return; + + this.setProperty({ + obj, + key: `${key}.${new_key}`, + value: el, + value_type: new_value_type, + raw_key: `${raw_key}.${new_raw_key}`, + visited, + }); + }); + break; + } + case "object": { + this.flattenObject({ + to_flatten: value as Record, + parent_key: key, + result: obj, + raw_parent_key: raw_key, + visited, + }); + break; + } + } + }, + /** + * Flattens an object by recursively iterating through its properties and creating a new object with flattened keys. + * + * @param to_flatten - The object to be flattened. + * @param parent_key - The parent key used for nested properties. Default is an empty string. + * @param raw_parent_key - The raw parent key used for nested properties. Default is an empty string. + * @param result - The result object that stores the flattened properties. Default is a new instance of Flattened. + * @returns The flattened object. + */ + flattenObject({ + to_flatten, + parent_key = "", + raw_parent_key = "", + result = new Flattened(), + visited = new Map(), + }: { + to_flatten: object; + parent_key?: string; + raw_parent_key?: string; + result?: Flattened; + visited?: Map; + }): Flattened { + // Check if the object has already been visited + if (visited.has(to_flatten)) { + const circ_path = visited.get(to_flatten); + + this.setProperty({ + // Replace the last value type (likely object - :o) with the circular type (:c) + key: `${parent_key.substring(0, parent_key.length - 2)}:c`, + obj: result, + raw_key: raw_parent_key, + value: circ_path === "" ? "__root__" : circ_path, + value_type: "circular", + visited, + }); + return result; + } + + // Add the object to the visited set + visited.set(to_flatten, raw_parent_key); + const entries = Object.entries(to_flatten); + + for (const [key_name, value] of entries) { + // Encode key parts to handle special characters and array-like keys. + const { value_type, key } = this.getType(key_name, value); + const new_key = parent_key ? `${parent_key}.${key}` : key; + const new_raw_parent_key = raw_parent_key + ? `${raw_parent_key}.${key_name}` + : key_name; + + this.setProperty({ + obj: result, + key: new_key, + value, + value_type, + raw_key: new_raw_parent_key, + visited, + }); + } + + // Remove the object from the visited set after processing + // visited.delete(to_flatten); + + return result; + }, + }; + + /** + * Utility class for unflattening data. + */ + private static unflatten = { + /** + * Decodes a key part of a flattened object. + * @param part - The key part to decode. + * @returns An object containing the decoded key and value type. + */ + decodeKeyPart(part: string): { + key: string; + value_type: ValueType; + secondary_value_type?: ValueType; + } { + const [key, value_type_raw] = part.split(":"); + + const [value_type, secondary_value_type] = value_type_raw.split(";"); + + return { + key: decodeURIComponent(key), + value_type: Flattened.key_postfix_rev[value_type], + secondary_value_type: Flattened.key_postfix_rev[secondary_value_type], + }; + }, + + /** + * Sets the value of a property in an object or array based on the specified key and value type. + * @param obj - The object or array to modify. + * @param key - The key of the property to set. + * @param value - The value to set. + * @param value_type - The type of the value. + * @param raw_key - The raw key string. + */ + setProperty({ + obj, + key, + value, + value_type, + }: { + obj: Record | unknown[]; + key: string; + value: unknown; + value_type: ValueType; + raw_key: string; + }): void { + switch (value_type) { + case "string": + case "boolean": + case "number": + case "circular": + (obj as Record)[key] = value; + break; + case "undefined": + (obj as Record)[key] = undefined; + break; + case "null": + (obj as Record)[key] = null; + break; + case "bigint": { + (obj as Record)[key] = BigInt(value as string); + break; + } + case "array": { + if (!isArray((obj as Record)[key])) + (obj as Record)[key] = []; + break; + } + case "object": { + if (!isObject((obj as Record)[key])) + (obj as Record)[key] = {}; + break; + } + } + }, + + /** + * Unflattens a flattened object. + * @param flattened - The flattened object to unflatten. + * @returns The unflattened object. + */ + unflattenObject({ + flattened, + }: { flattened: Flattened }): Record { + const data = flattened.__data; + const result: Record = {}; + + // For each entry in the flattened array, set the property in the result object. + for (const [raw_flattened_key, val] of Object.entries(data)) { + // Split the keys by the dot to get the nested keys. + const keys = raw_flattened_key.split("."); + // Create a reference object for this specific loop to keep track of where to set the key + let ref = result; + + for (let i = 0; i < keys.length; i++) { + // Get the key part and value type + const encoded_key = keys[i]; + // const next = keys[i + 1] + const { key, value_type, secondary_value_type } = + this.decodeKeyPart(encoded_key); + + switch (value_type) { + case "string": + case "boolean": + case "number": + case "undefined": + case "null": + this.setProperty({ + key, + value: val, + raw_key: encoded_key, + obj: ref, + value_type: value_type, + }); + break; + case "array": + if (!isArray(ref[key])) ref[key] = []; + ref = ref[key] as Record; + break; + + case "object": + if (!isObject(ref[key])) ref[key] = {}; + ref = ref[key] as Record; + break; + + case "index": { + // If there is no secondary_value_type, then ignore this + if (!secondary_value_type) break; + + // Use the secondary_value_type to determine if the next key is an object or array + // If the next key is an object or array, create a new object or array and set that to the ref + if ( + secondary_value_type === "object" || + secondary_value_type === "array" + ) { + ref[key] = secondary_value_type === "object" ? {} : []; + ref = ref[key] as Record; + + break; + } + + // If the next key is not an object or array, set the value to the ref + this.setProperty({ + key, + value: val, + raw_key: encoded_key, + obj: ref, + value_type: secondary_value_type, + }); + break; + } + case "circular": { + // If the value type is circular, set the value to the ref + let referenced: object; + + if (val === "__root__") { + referenced = result; + } else { + referenced = getNestedKeyValue(result, val as string) as object; + } + + this.setProperty({ + key, + value: referenced, + raw_key: encoded_key, + obj: ref, + value_type: value_type, + }); + break; + } + } + } + } + + return result; + }, + }; +} diff --git a/util/format.ts b/util/format.ts index 47e1582..744380a 100644 --- a/util/format.ts +++ b/util/format.ts @@ -1,177 +1,188 @@ -import { getNestedKeyValue } from "./obj.ts"; -import { FUNC_NAMES, FUNCS, getFunctionParameters } from "./function.ts"; import { FunctionType } from "../types/fn.ts"; +import { FUNCS, FUNC_NAMES, getFunctionParameters } from "./function.ts"; +import { getNestedKeyValue } from "./obj.ts"; const DYN_STR_REGEX = - /\[\[~\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; + /\[\[~\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; const DYN_CASEKEY_REGEX = - /(?(?:(?:[\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; + /(?(?:(?:[\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|CUSTOM|(?:LEN_)?X?OR|(?:LEN_)?AND)\((?[^)]+)\)/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; +/** + * Formats a string by replacing dynamic placeholders with corresponding values from the provided data object. + * + * @template T - The type of the data object. + * @param str - The string to be formatted. + * @param data - The data object containing values for dynamic placeholders. + * @param functions - An optional object containing custom functions for dynamic comparisons. + * @returns The formatted string. + */ 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, - _key: string, - _dyn_field: string, - _case_key: string, - _unk, - _src_str: string, - groups: { data_key: string; case_key: string; cases: string }, - ) => { - let cur_val = getNestedKeyValue(ctx, groups["data_key"]); - - if (cur_val === undefined) { - cur_val = "default"; - } - - // collect all the options into an array - // const options = dyn_field.matchAll(DYN_CASEKEY_REGEX); - const options = groups.cases.matchAll(DYN_CASEKEY_REGEX); - - // Build an options map from the regex result iterable - const options_map = new Map(); - for (const option of options) { - options_map.set( - option.groups?.case_key as string, - (option.groups?.content_a || option.groups?.content_b) as string, - ); - } - - // Handle running comparison functions on provided values - let func_case_val: string | undefined = undefined; - options_map.forEach((val, key) => { - // If there's already a value, skip all future iterations - if (func_case_val !== undefined) return; - // If the key matches DYN_FUNC_REGEX, run it and if it returns true, set the value to func_case_val - if (DYN_FUNC_REGEX.test(key)) { - // Get the separated function name - const func_name = key.substring(0, key.indexOf("(")).toUpperCase(); - // Get the arguments and split them by commas, then trim the whitespace - - // Make sure the function exists - if (!FUNC_NAMES.includes(func_name)) { - return; - } - - // 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; - - // Get the function object to make sure the argument lengths meet the minimum for the function - const func_obj = FUNCS[func_name as keyof typeof FUNCS]; - if ( - (func_obj.arg_count === 1 && arg1 === undefined) || - (func_obj.arg_count === 2 && arg2 === undefined) - ) { - return; - } - - // Get the available types for each function variable - const [arg1_valid_types, arg2_valid_types = []] = func_obj.arg_types; - - // Make sure the type of the above variables match with whats allowed in the func_obj.arg_types array - if (!arg1_type || !arg1_valid_types.includes(arg1_type)) { - // Exit out early if the type for the second variable is invalid - return; - } - - // Make sure the types of arg 2 are valid as well, or if the function only needs one argument, set it to true - if ( - func_obj.arg_count !== 1 && - (!arg2_type || !arg2_valid_types.includes(arg2_type)) - ) { - // Exit out early if the type for the second variable is invalid - 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); - - // If the result is true, set the value of this key to the - // external func_case_val variable for further use - if (result === true) { - func_case_val = val; - return; - } else { - return; - } - } - }); - - // If one of the functions above returned true, return the value of the case that matched - if (func_case_val !== undefined) { - return func_case_val; - } - - // Cast the value to a string in case people are using numbers - const val_str = new String(cur_val).toString(); - // If the current value is not in the options, use the default - if (options_map.has(val_str as string)) { - return options_map.get(val_str as string) as string; - } else if (options_map.has("default")) { - return options_map.get("default") as string; - } else { - return "[fallback_key_missing]"; - } - }, - ); - - // Proceed to replace any instances of {{object.nested.key}} (optionally formatted with a fallback string as {{}}||fallback string|| ) with their appropriate values - const formatted = translated.replaceAll( - DYN_NESTED_REGEX, - (_match, key, fallback) => { - const val = getNestedKeyValue(ctx, key); - if (val === undefined) { - return fallback || "[no_value]"; - } - - return val as string; - }, - ); - - return formatted; + str = "", + data?: T, + functions?: Record>, +): string { + 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, + _key: string, + _dyn_field: string, + _case_key: string, + _unk, + _src_str: string, + groups: { data_key: string; case_key: string; cases: string }, + ) => { + let cur_val = getNestedKeyValue(ctx, groups.data_key); + + if (cur_val === undefined) { + cur_val = "default"; + } + + // collect all the options into an array + // const options = dyn_field.matchAll(DYN_CASEKEY_REGEX); + const options = groups.cases.matchAll(DYN_CASEKEY_REGEX); + + // Build an options map from the regex result iterable + const options_map = new Map(); + for (const option of options) { + options_map.set( + option.groups?.case_key as string, + (option.groups?.content_a || option.groups?.content_b) as string, + ); + } + + // Handle running comparison functions on provided values + let func_case_val: string | undefined = undefined; + options_map.forEach((val, key) => { + // If there's already a value, skip all future iterations + if (func_case_val !== undefined) return; + // If the key matches DYN_FUNC_REGEX, run it and if it returns true, set the value to func_case_val + if (DYN_FUNC_REGEX.test(key)) { + // Get the separated function name + const func_name = key.substring(0, key.indexOf("(")).toUpperCase(); + // Get the arguments and split them by commas, then trim the whitespace + + // Make sure the function exists + if (!FUNC_NAMES.includes(func_name)) { + return; + } + + // 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; + + // Get the function object to make sure the argument lengths meet the minimum for the function + const func_obj = FUNCS[func_name as keyof typeof FUNCS]; + if ( + (func_obj.arg_count === 1 && arg1 === undefined) || + (func_obj.arg_count === 2 && arg2 === undefined) + ) { + return; + } + + // Get the available types for each function variable + const [arg1_valid_types, arg2_valid_types = []] = func_obj.arg_types; + + // Make sure the type of the above variables match with whats allowed in the func_obj.arg_types array + if (!arg1_type || !arg1_valid_types.includes(arg1_type)) { + // Exit out early if the type for the second variable is invalid + return; + } + + // Make sure the types of arg 2 are valid as well, or if the function only needs one argument, set it to true + if ( + func_obj.arg_count !== 1 && + (!arg2_type || !arg2_valid_types.includes(arg2_type)) + ) { + // Exit out early if the type for the second variable is invalid + 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); + + // If the result is true, set the value of this key to the + // external func_case_val variable for further use + if (result === true) { + func_case_val = val; + return; + } + + return; + } + }); + + // If one of the functions above returned true, return the value of the case that matched + if (func_case_val !== undefined) { + return func_case_val; + } + + // Cast the value to a string in case people are using numbers + const val_str = new String(cur_val).toString(); + // If the current value is not in the options, use the default + if (options_map.has(val_str as string)) { + return options_map.get(val_str as string) as string; + } + + if (options_map.has("default")) { + return options_map.get("default") as string; + } + + return "[fallback_key_missing]"; + }, + ); + + // Proceed to replace any instances of {{object.nested.key}} (optionally formatted with a fallback string as {{}}||fallback string|| ) with their appropriate values + const formatted = translated.replaceAll( + DYN_NESTED_REGEX, + (_match, key, fallback) => { + const val = getNestedKeyValue(ctx, key); + if (val === undefined) { + return fallback || "[no_value]"; + } + + return val as string; + }, + ); + + return formatted; } diff --git a/util/function.ts b/util/function.ts index c29686d..5bcda31 100644 --- a/util/function.ts +++ b/util/function.ts @@ -7,16 +7,16 @@ 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*(?:{(?.*?)}))|(?: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; + /(?(?:str\s*:\s*\`(?[^`]*)\`)|(?:key\s*:\s*(?:{\s*(?[^{}]*?)\s*}))|(?:fn\s*:\s*(?:{\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)) { - return a.length; - } else if (typeof a === "object") { - return Object.keys(a).length; - } else { - return Number.NaN; - } + if (typeof a === "string" || Array.isArray(a)) { + return a.length; + } + if (typeof a === "object") { + return Object.keys(a).length; + } + return Number.NaN; }; const funcGT = (a: ArgType, b: ArgType) => a > b; @@ -31,7 +31,7 @@ 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 funcBT = (a: ArgType, b: ArgType, c: ArgType) => - funcGT(a, b) && funcLT(a, c); + 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); @@ -47,342 +47,363 @@ const funcLEN_LTE = (a: ArgType, b: ArgType) => getLen(a) <= b; const funcLEN_NLT = (a: ArgType, b: ArgType) => !funcLT(getLen(a), b); const funcLEN_NLTE = (a: ArgType, b: ArgType) => !funcLTE(getLen(a), b); const funcLEN_EQ = (a: ArgType | boolean, b: ArgType | boolean) => - getLen(a as ArgType) === b; + getLen(a as ArgType) === b; const funcLEN_NEQ = (a: ArgType | boolean, b: ArgType | boolean) => - getLen(a as ArgType) !== b; + getLen(a as ArgType) !== b; const funcLEN_BT = (a: ArgType, b: ArgType, c: ArgType) => - funcGT(getLen(a), b) && funcLT(getLen(a), c); + funcGT(getLen(a), b) && funcLT(getLen(a), c); const funcLEN_NBT = (a: ArgType, b: ArgType, c: ArgType) => - !funcBT(getLen(a), b, c); + !funcBT(getLen(a), b, c); const funcLEN_IN = (a: ArgType, b: ArgType[] | string) => - b.includes(getLen(a) as string); + b.includes(getLen(a) as string); const funcLEN_NIN = (a: ArgType, b: ArgType[] | string) => - !funcIN(getLen(a), b); + !funcIN(getLen(a), b); 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"]], - func: funcGT, - }, - GTE: { - arg_count: 1, - arg_types: [["num", "str", "key"]], - func: funcGTE, - }, - NGT: { - arg_count: 1, - arg_types: [["num", "str", "key"]], - func: funcNGT, - }, - NGTE: { - arg_count: 1, - arg_types: [["num", "str", "key"]], - func: funcNGTE, - }, - LEN_GT: { - arg_count: 1, - arg_types: [["num", "str", "key"]], - func: funcLEN_GT, - }, - LEN_GTE: { - arg_count: 1, - arg_types: [["num", "str", "key"]], - func: funcLEN_GTE, - }, - LEN_NGT: { - arg_count: 1, - arg_types: [["num", "str", "key"]], - func: funcLEN_NGT, - }, - LEN_NGTE: { - arg_count: 1, - arg_types: [["num", "str", "key"]], - func: funcLEN_NGTE, - }, + // GT functions + GT: { + arg_count: 1, + arg_types: [["num", "str", "key"]], + func: funcGT, + }, + GTE: { + arg_count: 1, + arg_types: [["num", "str", "key"]], + func: funcGTE, + }, + NGT: { + arg_count: 1, + arg_types: [["num", "str", "key"]], + func: funcNGT, + }, + NGTE: { + arg_count: 1, + arg_types: [["num", "str", "key"]], + func: funcNGTE, + }, + LEN_GT: { + arg_count: 1, + arg_types: [["num", "str", "key"]], + func: funcLEN_GT, + }, + LEN_GTE: { + arg_count: 1, + arg_types: [["num", "str", "key"]], + func: funcLEN_GTE, + }, + LEN_NGT: { + arg_count: 1, + arg_types: [["num", "str", "key"]], + func: funcLEN_NGT, + }, + LEN_NGTE: { + arg_count: 1, + arg_types: [["num", "str", "key"]], + func: funcLEN_NGTE, + }, - LT: { - arg_count: 1, - arg_types: [["num", "str", "key"]], - func: funcLT, - }, - LTE: { - arg_count: 1, - arg_types: [["num", "str", "key"]], - func: funcLTE, - }, - NLT: { - arg_count: 1, - arg_types: [["num", "str", "key"]], - func: funcNLT, - }, - NLTE: { - arg_count: 1, - arg_types: [["num", "str", "key"]], - func: funcNLTE, - }, - LEN_LT: { - arg_count: 1, - arg_types: [["num", "str", "key"]], - func: funcLEN_LT, - }, - LEN_LTE: { - arg_count: 1, - arg_types: [["num", "str", "key"]], - func: funcLEN_LTE, - }, - LEN_NLT: { - arg_count: 1, - arg_types: [["num", "str", "key"]], - func: funcLEN_NLT, - }, - LEN_NLTE: { - arg_count: 1, - arg_types: [["num", "str", "key"]], - func: funcLEN_NLTE, - }, + LT: { + arg_count: 1, + arg_types: [["num", "str", "key"]], + func: funcLT, + }, + LTE: { + arg_count: 1, + arg_types: [["num", "str", "key"]], + func: funcLTE, + }, + NLT: { + arg_count: 1, + arg_types: [["num", "str", "key"]], + func: funcNLT, + }, + NLTE: { + arg_count: 1, + arg_types: [["num", "str", "key"]], + func: funcNLTE, + }, + LEN_LT: { + arg_count: 1, + arg_types: [["num", "str", "key"]], + func: funcLEN_LT, + }, + LEN_LTE: { + arg_count: 1, + arg_types: [["num", "str", "key"]], + func: funcLEN_LTE, + }, + LEN_NLT: { + arg_count: 1, + arg_types: [["num", "str", "key"]], + func: funcLEN_NLT, + }, + LEN_NLTE: { + arg_count: 1, + arg_types: [["num", "str", "key"]], + func: funcLEN_NLTE, + }, - EQ: { - arg_count: 1, - arg_types: [["num", "str", "key", "bool"]], - func: funcEQ, - }, - NEQ: { - arg_count: 1, - arg_types: [["num", "str", "key", "bool"]], - func: funcNEQ, - }, - LEN_EQ: { - arg_count: 1, - arg_types: [["num", "str", "key", "bool"]], - func: funcLEN_EQ, - }, - LEN_NEQ: { - arg_count: 1, - arg_types: [["num", "str", "key", "bool"]], - func: funcLEN_NEQ, - }, + EQ: { + arg_count: 1, + arg_types: [["num", "str", "key", "bool"]], + func: funcEQ, + }, + NEQ: { + arg_count: 1, + arg_types: [["num", "str", "key", "bool"]], + func: funcNEQ, + }, + LEN_EQ: { + arg_count: 1, + arg_types: [["num", "str", "key", "bool"]], + func: funcLEN_EQ, + }, + LEN_NEQ: { + arg_count: 1, + arg_types: [["num", "str", "key", "bool"]], + func: funcLEN_NEQ, + }, - BT: { - arg_count: 2, - arg_types: [ - ["num", "str", "key"], - ["num", "str", "key"], - ], - func: funcBT, - }, - NBT: { - arg_count: 2, - arg_types: [ - ["num", "str", "key"], - ["num", "str", "key"], - ], - func: funcNBT, - }, - LEN_BT: { - arg_count: 2, - arg_types: [ - ["num", "str", "key"], - ["num", "str", "key"], - ], - func: funcLEN_BT, - }, - LEN_NBT: { - arg_count: 2, - arg_types: [ - ["num", "str", "key"], - ["num", "str", "key"], - ], - func: funcLEN_NBT, - }, + BT: { + arg_count: 2, + arg_types: [ + ["num", "str", "key"], + ["num", "str", "key"], + ], + func: funcBT, + }, + NBT: { + arg_count: 2, + arg_types: [ + ["num", "str", "key"], + ["num", "str", "key"], + ], + func: funcNBT, + }, + LEN_BT: { + arg_count: 2, + arg_types: [ + ["num", "str", "key"], + ["num", "str", "key"], + ], + func: funcLEN_BT, + }, + LEN_NBT: { + arg_count: 2, + arg_types: [ + ["num", "str", "key"], + ["num", "str", "key"], + ], + func: funcLEN_NBT, + }, - IN: { - arg_count: 1, - arg_types: [["key"]], - func: funcIN, - }, - NIN: { - arg_count: 1, - arg_types: [["key"]], - func: funcNIN, - }, - LEN_IN: { - arg_count: 1, - arg_types: [["key"]], - func: funcLEN_IN, - }, - LEN_NIN: { - arg_count: 1, - arg_types: [["key"]], - func: funcLEN_NIN, - }, + IN: { + arg_count: 1, + arg_types: [["key"]], + func: funcIN, + }, + NIN: { + arg_count: 1, + arg_types: [["key"]], + func: funcNIN, + }, + LEN_IN: { + arg_count: 1, + arg_types: [["key"]], + func: funcLEN_IN, + }, + LEN_NIN: { + arg_count: 1, + arg_types: [["key"]], + func: funcLEN_NIN, + }, - AND: { - arg_count: 1, - arg_types: [["num", "str", "key", "bool"]], - func: funcAND, - }, - OR: { - arg_count: 1, - arg_types: [["num", "str", "key", "bool"]], - func: funcOR, - }, - XOR: { - arg_count: 1, - arg_types: [["num", "str", "key", "bool"]], - func: funcXOR, - }, - CUSTOM: { - arg_count: 1, - arg_types: [["fn"]], - func: funcCUSTOM, - }, + AND: { + arg_count: 1, + arg_types: [["num", "str", "key", "bool"]], + func: funcAND, + }, + OR: { + arg_count: 1, + arg_types: [["num", "str", "key", "bool"]], + func: funcOR, + }, + XOR: { + arg_count: 1, + 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 = >( - str: string, - 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; - }>; +/** + * Retrieves the parameters from a string representation of a function call. + * + * @template T - The type of the data object. + * @param str - The string representation of the function call. + * @param data - The data object used for resolving key arguments. + * @param functions - The functions object used for resolving function arguments. + * @returns An array of function parameters, including their values and types. + * @example + */ +export function getFunctionParameters>( + str: string, + data: Record = {}, + functions?: Record, +): ( + | { + val: + | string + | number + | boolean + | FunctionType + | ((a: unknown) => boolean); + type: FuncParamType; + } + | undefined +)[] { + 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 - | FunctionType - | ((a: unknown) => boolean); - type: FuncParamType; - } - | undefined - )[] = []; + const args: ( + | { + val: + | string + | number + | boolean + | FunctionType + | ((a: unknown) => boolean); + type: FuncParamType; + } + | undefined + )[] = []; - if (arg1) { - const type = arg1.split(":")[0] as FuncParamType; - let arg: string | number | boolean | FunctionType | undefined = - undefined; + if (arg1) { + 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(ctx, arg1_val_key as string) as - | string - | number - | boolean - | undefined; - } else if (type === "num" && arg1_val_num !== undefined) { - const tmp_num = arg1_val_num.includes(".") - ? Number.parseFloat(arg1_val_num as string) - : Number.parseInt(arg1_val_num as string); + // 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(ctx, arg1_val_key as string) as + | string + | number + | boolean + | undefined; + } else if (type === "num" && arg1_val_num !== undefined) { + const tmp_num = arg1_val_num.includes(".") + ? Number.parseFloat(arg1_val_num as string) + : Number.parseInt(arg1_val_num as string); - // If the argument isn't a valid number, discard it - if (Number.isNaN(tmp_num)) { - arg = undefined; - } else { - arg = tmp_num; - } - } else if (type === "bool" && arg1_val_bool !== undefined) { - switch (arg1_val_bool) { - case "true": - case "1": - arg = true; - break; - case "false": - case "0": - arg = false; - break; - default: - arg = undefined; - break; - } - } else if (type === "fn" && arg1_val_fn !== undefined) { - arg = getNestedKeyValue(fns, arg1_val_fn as string) as FunctionType; - } + // If the argument isn't a valid number, discard it + if (Number.isNaN(tmp_num)) { + arg = undefined; + } else { + arg = tmp_num; + } + } else if (type === "bool" && arg1_val_bool !== undefined) { + switch (arg1_val_bool) { + case "true": + case "1": + arg = true; + break; + case "false": + case "0": + arg = false; + break; + default: + 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); - } + args.push(arg !== undefined ? { val: arg, type } : undefined); + } - // If the function requires a second (really third if you count - // the comparison passed in at the beginning of the dynamic - // replacer) argument, parse it from the provided ar2 variables - // from the regex - if (arg2) { - const type = arg2.split(":")[0] as FuncParamType; - let arg: string | number | boolean | FunctionType | undefined = - undefined; + // If the function requires a second (really third if you count + // the comparison passed in at the beginning of the dynamic + // replacer) argument, parse it from the provided ar2 variables + // from the regex + if (arg2) { + 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(ctx, arg2_val_key as string) as - | string - | number - | boolean - | undefined; - } else if (type === "num" && arg2_val_num !== undefined) { - arg = arg2_val_num.includes(".") - ? Number.parseFloat(arg2_val_num as string) - : Number.parseInt(arg2_val_num as string); - // If the argument isn't a valid number, discard it - if (Number.isNaN(arg)) { - arg = undefined; - } - } else if (type === "bool" && arg2_val_bool !== undefined) { - switch (arg2_val_bool) { - case "true": - case "1": - arg = true; - break; - case "false": - case "0": - arg = false; - break; - default: - arg = undefined; - break; - } - } else if (type === "fn" && arg2_val_fn !== undefined) { - arg = getNestedKeyValue(fns, arg2_val_fn as string) as FunctionType; - } + // 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(ctx, arg2_val_key as string) as + | string + | number + | boolean + | undefined; + } else if (type === "num" && arg2_val_num !== undefined) { + arg = arg2_val_num.includes(".") + ? Number.parseFloat(arg2_val_num as string) + : Number.parseInt(arg2_val_num as string); + // If the argument isn't a valid number, discard it + if (Number.isNaN(arg)) { + arg = undefined; + } + } else if (type === "bool" && arg2_val_bool !== undefined) { + switch (arg2_val_bool) { + case "true": + case "1": + arg = true; + break; + case "false": + case "0": + arg = false; + break; + default: + 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); - } + args.push(arg ? { val: arg, type } : undefined); + } - return args; -}; + return args; +} diff --git a/util/obj.ts b/util/obj.ts index 2b28af4..22a10bd 100644 --- a/util/obj.ts +++ b/util/obj.ts @@ -1,42 +1,3 @@ -/** - * Flatten an object into a single level using dot notation - * @param obj Object to flatten - * @param cur_key Current key if has recursed - * @returns Flattened object using dot notation - * @example flattenObject({a: {b: {c: 'd'}}}) // {a.b.c: 'd'} - * @example flattenObject({a: [{b: 'c'}]}) // {a.0.b: 'c'} - * @example flattenObject({a: [{b: {c: 'd'}}, 'e']}) // {a.0.b.c: 'd', a.1: 'e'} - */ -export const flattenObject = ( - obj: Record, - cur_key = "", -): Record => { - const flattened: Record = {}; - - const handleTypes = (val: unknown, key = "") => { - if (Array.isArray(val)) { - val.forEach((item, index) => { - handleTypes(item, `${key}.${index}`); - }); - } else if (typeof val === "object") { - Object.assign( - flattened, - flattenObject(val as Record, key), - ); - } else { - flattened[key] = val as string; - } - }; - - Object.keys(obj).forEach((key: string) => { - const val = obj[key]; - - handleTypes(val, `${cur_key}${cur_key ? "." : ""}${key}`); - }); - - return flattened; -}; - /** * Get a nested key value from an object * @param obj Object to get the nested key of @@ -44,20 +5,19 @@ export const flattenObject = ( * @returns The value of the nested key or undefined */ export const getNestedKeyValue = ( - obj: Record, - key: string, + obj: Record, + key: string, ): unknown | undefined => { - const keys = key.split("."); - let cur_obj = obj; + const keys = key.split("."); + let cur_obj = obj; - for (let i = 0; i < keys.length; i++) { - const cur_key = keys[i]; - if (cur_obj[cur_key] === undefined) { - return undefined; - } else { - cur_obj = cur_obj[cur_key] as Record; - } - } + for (let i = 0; i < keys.length; i++) { + const cur_key = keys[i]; + if (cur_obj[cur_key] === undefined) { + return undefined; + } + cur_obj = cur_obj[cur_key] as Record; + } - return cur_obj; + return cur_obj; };