Pārlūkot izejas kodu

feat: complete translation system overhaul

Sv443 4 mēneši atpakaļ
vecāks
revīzija
9e34af46b4
3 mainītis faili ar 656 papildinājumiem un 423 dzēšanām
  1. 11 6
      .changeset/beige-dots-refuse.md
  2. 278 268
      README.md
  3. 367 149
      lib/translation.ts

+ 11 - 6
.changeset/beige-dots-refuse.md

@@ -2,10 +2,15 @@
 "@sv443-network/userutils": major
 ---
 
-Reworked translation system:
-- **BREAKING:** Renamed function `tr.addLanguage()` to `tr.addTranslations()`
+**BREAKING** - Reworked translation system:
+- Removed `tr()`, `tr.setLanguage()` and `tr.getLanguage()`
+- Renamed function `tr.addLanguage()` to `tr.addTranslations()`
+- Disabled `%n`-based argument insertion by default (re-enable with `tr.addTransform(tr.transforms.percent)`)
 - Added functions:
-  - `tr.deleteTranslations()` to delete translations for the given or active language
-  - `tr.hasKey()` to check if the translation with the given key exists for the given or active language
-  - `tr.addTransform()` to add a transform function for all languages that can dynamically modify translations
-  - `tr.deleteTransform()` to delete a previously registered transform function
+  - `tr.for()` - translates a key for the specified language
+  - `tr.use()` - creates a translation function for the specified language for much easier usage
+  - `tr.hasKey()` - checks if a key exists in the given language
+  - `tr.setFallbackLanguage()` - sets the fallback language used when a key is not found in the given language
+  - `tr.getFallbackLanguage()` - returns the fallback language
+  - `tr.addTransform()` - adds a transform function to the translation system, allowing for custom argument insertion and much more
+  - `tr.deleteTransform()` - removes a transform function

+ 278 - 268
README.md

@@ -75,16 +75,18 @@ View the documentation of previous major releases:
     - [`takeRandomItem()`](#takerandomitem) - returns a random item from an array and mutates it to remove the item
     - [`randomizeArray()`](#randomizearray) - returns a copy of the array with its items in a random order
   - [**Translation:**](#translation)
-    - [`tr()`](#tr) - simple JSON-based translation system with placeholder and nesting support
-    - [`tr.forLang()`](#trforlang) - translate with the specified language instead of the currently active one
-    - [`tr.setLanguage()`](#trsetlanguage) - set the currently active language for translations
-    - [`tr.getLanguage()`](#trgetlanguage) - returns the currently active language
-    - [`tr.addTranslations()`](#traddtranslations) - add a language and its translations
-    - [`tr.getTranslations()`](#trgettranslations) - returns the translations for the given language or the currently active one
-    - [`tr.deleteTranslations()`](#trdeletetranslations) - delete the translations for the given language or the active one
-    - [`tr.hasKey()`](#trhaskey) - check if a translation key exists for the given or active language
-    - [`tr.addTransform()`](#traddtransform) - add a transformation function to dynamically modify the translation value
-    - [`tr.deleteTransform()`](#trdeletetransform) - delete a transformation function that was previously added
+    - [`tr.for()`](#trfor) - translates a key for the specified language
+    - [`tr.use()`](#truse) - creates a translation function for the specified language
+    - [`tr.hasKey()`](#trhaskey) - checks if a key exists in the given language
+    - [`tr.addTranslations()`](#traddtranslations) - add a flat or recursive translation object for a language
+    - [`tr.getTranslations()`](#trgettranslations) - returns the translation object for a language
+    - [`tr.deleteTranslations()`](#trdeletetranslations) - delete the translation object for a language
+    - [`tr.setFallbackLanguage()`](#trsetfallbacklanguage) - set the fallback language used when a key is not found in the given language
+    - [`tr.getFallbackLanguage()`](#trgetfallbacklanguage) - returns the fallback language
+    - [`tr.addTransform()`](#traddtransform) - adds a transform function to the translation system for custom argument insertion and much more
+    - [`tr.deleteTransform()`](#trdeletetransform) - removes a transform function
+    - [`tr.transforms`](#trtransforms) - predefined transform functions for quickly adding custom argument insertion
+    - [`TrKeys`](#trkeys) - generic type that extracts all keys from a flat or recursive translation object into a union
   - [**Colors:**](#colors)
     - [`hexToRgb()`](#hextorgb) - convert a hex color string to an RGB or RGBA value tuple
     - [`rgbToHex()`](#rgbtohex) - convert RGB or RGBA values to a hex color string
@@ -2262,85 +2264,65 @@ console.log(foo); // [1, 2, 3, 4, 5, 6] - original array is not mutated
 <!-- #region Translation -->
 ## Translation:
 This is a very lightweight translation function that can be used to translate simple strings.  
-Pluralization is not supported but can be achieved manually by adding variations to the translations, identified by a different suffix. See the example section of [`tr.addTranslations()`](#traddtranslations) for an example on how this might be done.
+TODO: Pluralization is not supported but can be achieved manually by adding variations to the translations, identified by a different suffix. See the example section of [`tr.addTranslations()`](#traddtranslations) for an example on how this might be done.
 
 <br>
 
-### tr()
+### tr.for()
 Usage:  
 ```ts
-tr(key: string, ...insertValues: Stringifiable[]): string
+tr.for<TTrKey extends string = string>(language: string, key: TTrKey, ...args: Stringifiable[]): string
 ```
   
-The function returns the translation of the passed key in the language added by [`tr.addTranslations()`](#traddtranslations) and set by [`tr.setLanguage()`](#trsetlanguage)  
-Should the translation contain placeholders in the format `%n`, where `n` is the number of the value starting at 1, they will be replaced with the respective item of the `insertValues` rest parameter.  
-The items of the `insertValues` rest parameter will be stringified using `toString()` (see [Stringifiable](#stringifiable)) before being inserted into the translation.
+Returns the translation for a given key in the specified language.  
+If the key does not exist in the current language nor the fallback language set by [`tr.setFallbackLanguage()`](#trsetfallbacklanguage), the key itself will be returned.  
+The `args` parameter is used for argument insertion, provided a transform function was set up via [`tr.addTransform()`](#traddtransform).  
   
-Should you be using nested objects in your translations, you can use the dot notation to access them.  
-First, the key will be split by dots and the parts will be used to traverse the translation object.  
-If that doesn't yield a result, the function will try to access the key including dots on the top level of the translation object.  
-If that also doesn't yield a result, the key itself will be returned.  
+You should probably prefer to use [`tr.use()`](#truse), as it provides a more convenient way to translate multiple strings without having to repeat the language parameter.  
   
-If no language has been added or set before calling this function, it will also return the key itself.  
-  
-To check if a translation has been found, compare the returned value with the key. If they are the same, the translation was not found.  
-You could also write a wrapper function that can then return a default value or `null` if the translation was not found instead.  
-  
-If the key is found and the translation contains placeholders but none or an insufficient amount of values are passed, it will try to insert as many values as were passed and leave the rest of the placeholders untouched in their `%n` format.  
-If the key is found, the translation doesn't contain placeholders but values are still passed, the values will be ignored and the translation will be returned without modification.  
-  
-You may use [`tr.addTransform()`](#traddtransform) to add a function that will be called on every translation matching a given pattern, allowing for very dynamic translations. See examples by going to that function's section.  
+The generic `TTrKey` can be used to enforce type safety for the keys.  
+You can pass the result of the generic type [`TrKeys`](#trkeys) to easily generate a union type of all keys in the given translation object.  
   
 <details><summary><b>Example - click to view</b></summary>
 
 ```ts
 import { tr } from "@sv443-network/userutils";
 
-// add languages and translations:
 tr.addTranslations("en", {
-  welcome: {
-    generic: "Welcome",
-    with_name: "Welcome, %1",
-  },
+  hello: "Hello, World!",
+  goodbye: "Goodbye, World!",
 });
 
 tr.addTranslations("de", {
-  welcome: {
-    generic: "Willkommen",
-    with_name: "Willkommen, %1",
-  },
+  hello: "Hallo, Welt!",
+  // goodbye is missing here
 });
 
-// this has to be called at least once before calling tr()
-tr.setLanguage("en");
-
-console.log(tr("welcome.generic"));           // "Welcome"
-console.log(tr("welcome.with_name", "John")); // "Welcome, John"
-
-console.log(tr("non_existent_key")); // "non_existent_key"
-console.log(tr("welcome"));          // "welcome" (because anything that isn't a string will make the function return the key itself)
-
-// language can be changed at any time, synchronously
-tr.setLanguage("de");
-
-console.log(tr("welcome.generic")); // "Willkommen"
+tr.setFallbackLanguage("en");
 
-// or without overwriting the current language:
+tr.for("en", "hello"); // "Hello, World!"
+tr.for("de", "hello"); // "Hallo, Welt!"
 
-console.log(tr.forLang("en", "welcome.generic")); // "Welcome"
+// these calls fall back to "en":
+tr.for("de", "goodbye");      // "Goodbye, World!"
+tr.for(undefined, "goodbye"); // "Goodbye, World!"
 ```
 </details>
 
 <br>
 
-### tr.forLang()
+### tr.use()
 Usage:  
 ```ts
-tr.forLang(language: string, key: string, ...insertValues: Stringifiable[]): string
-```  
+tr.use<TTrKey extends string = string>(language: string): (key: TTrKey, ...args: Stringifiable[]) => string
+```
+  
+Returns a function that can be used to translate strings in the specified language.  
+This allows you to only specify the language once and then translate multiple strings without having to repeat the language parameter.  
+The returned function works exactly like [`tr.for()`](#trfor), minus the language parameter.  
   
-Returns the translation of the passed key in the specified language. Otherwise behaves exactly like [`tr()`](#tr)  
-This function does not change the currently active language set by [`tr.setLanguage()`](#trsetlanguage)  
+The generic `TTrKey` can be used to enforce type safety for the keys.  
+You can pass the result of the generic type [`TrKeys`](#trkeys) to easily generate a union type of all keys in the given translation object.  
   
 <details><summary><b>Example - click to view</b></summary>
 
@@ -2348,215 +2330,220 @@ This function does not change the currently active language set by [`tr.setLangu
 import { tr } from "@sv443-network/userutils";
 
 tr.addTranslations("en", {
-  "welcome_name": "Welcome, %1",
+  hello: "Hello, World!",
 });
 
-tr.addTranslations("de", {
-  "welcome_name": "Willkommen, %1",
-});
+const t = tr.use("en");
 
-// the language is set to "en"
-tr.setLanguage("en");
-
-console.log(tr("welcome_name", "John"));               // "Welcome, John"
-// no need to call tr.setLanguage():
-console.log(tr.forLang("de", "welcome_name", "John")); // "Willkommen, John"
+// very concise and easy to use:
+t("hello"); // "Hello, World!"
 ```
 </details>
 
 <br>
 
-### tr.setLanguage()
+### tr.hasKey()
 Usage:  
 ```ts
-tr.setLanguage(language: string): void
+tr.hasKey<TTrKey extends string = string>(language: string | undefined, key: TTrKey): boolean
 ```
-
-Synchronously sets the language that will be used for translations by default.  
-Alternatively, you can use [`tr.forLang()`](#trforlang) to get translations in a different language without changing the current language.  
-No validation is done on the passed language, so make sure it is correct and it has been added with `tr.addTranslations()` before calling `tr()`  
   
-For an example, please see [`tr()`](#tr)
-
-<br>
+Returns `true` if the given key exists in the specified language, else `false`.  
+If no language parameter was provided, it will default to the fallback language set by [`tr.setFallbackLanguage()`](#trsetfallbacklanguage).  
+  
+The generic `TTrKey` can be used to enforce type safety for the keys.  
+You can pass the result of the generic type [`TrKeys`](#trkeys) to easily generate a union type of all keys in the given translation object.  
+  
+<details><summary><b>Example - click to view</b></summary>
 
-### tr.getLanguage()
-Usage:  
 ```ts
-tr.getLanguage(): string | undefined
-```
+import { tr } from "@sv443-network/userutils";
+
+tr.addTranslations("en", {
+  hello: "Hello, World!",
+});
 
-Returns the currently active language set by [`tr.setLanguage()`](#trsetlanguage)  
-If no language has been set yet, it will return undefined.
+tr.hasKey("en", "hello");   // true
+tr.hasKey("en", "goodbye"); // false
+```
+</details>
 
 <br>
 
 ### tr.addTranslations()
 Usage:  
 ```ts
-tr.addTranslations(language: string, translations: Record<string, string | object>): void
+tr.addTranslations(language: string, translations: TrObject): void
 ```
-
-Adds or overwrites a language and its associated translations.  
-The passed language can be any unique identifier, though I highly recommend sticking to a standard like [BCP 47 / RFC 5646](https://www.rfc-editor.org/rfc/rfc5646.txt) (which is used by the [`Intl` namespace](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl) and methods like [`Number.toLocaleString()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Number/toLocaleString)), or [ISO 639-1.](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes)  
-The passed translations can either be a flat object where the key is the translation key used in `tr()` and the value is the translation itself, or an infinitely nestable object structure containing the same.  
-If `tr.addTranslations()` is called multiple times with the same language, the previous translations of that language will be overwritten.  
   
-The translation values may contain placeholders in the format `%n`, where `n` is the number of the value starting at 1.  
-These can be used to inject values into the translation when calling [`tr()`](#tr)  
+Registers a translation object for the given language.  
+The translation object should be a key-value pair object where the keys are strings and the values are the translation strings.  
+The object can be flat or infinitely nested, but it may only contain JSON-serializable values.  
+If the object is nested, the keys are joined with a dot (`.`) to form the final key.  
   
 <details><summary><b>Example - click to view</b></summary>
 
 ```ts
-import { tr, type Stringifiable } from "@sv443-network/userutils";
+import { tr } from "@sv443-network/userutils";
 
-// add a language with associated translations:
+const trEn = {
+  hello: "Hello, World!",
+  nested: {
+    key: "This is a nested key",
+  },
+  "foo.bar": "This key isn't nested, it just has a dot",
+};
 
-tr.addTranslations("en", {
-  lang_name: "Eglis", // no worries, the example below will overwrite this value
-});
+tr.addTranslations("en", trEn);
 
-// overwriting previous translation, now with nested objects and placeholders:
+// full type safety and autocomplete:
+const t = tr.use<TrKeys<typeof trEn>>("en");
 
-tr.addTranslations("en", {
-  // to get this value, you could call `tr.forLang("en", "lang_name")`
-  lang_name: "English",
-  home_page: {
-    welcome: {
-      generic: "Welcome!",
-      // this can be accessed with `tr("home_page.welcome.name", "John")`
-      name: "Welcome, %1!",
-      extended: "Welcome, %1!\nYour last login was on %2\nYou have %3 unread messages",
-    },
-  },
-});
+t("hello");      // "Hello, World!"
+t("nested.key"); // "This is a nested key"
+t("foo.bar");    // "This key isn't nested, it just has a dot"
+```
+</details>
 
-// can be used for different locales too:
+<br>
 
-tr.addTranslations("en-US", {
-  fries: "fries",
-  color: "color",
-});
+### tr.getTranslations()
+Usage:  
+```ts
+tr.getTranslations(language: string): TrObject | undefined
+```
+  
+Returns the translation object for the given language.  
+If the language does not exist, it will return `undefined`.  
+  
+<details><summary><b>Example - click to view</b></summary>
+
+```ts
+import { tr } from "@sv443-network/userutils";
 
-tr.addTranslations("en-GB", {
-  fries: "chips",
-  color: "colour",
+tr.addTranslations("en", {
+  hello: "Hello, World!",
 });
 
-// apply default values for different locales to reduce redundancy in shared translation values:
+tr.getTranslations("en"); // { hello: "Hello, World!" }
+```
+</details>
 
-const translation_de = {
-  greeting: "Guten Tag!",
-  foo: "Foo",
-};
+<br>
 
-tr.addTranslations("de-DE", translation_de);
+### tr.deleteTranslations()
+Usage:  
+```ts
+tr.deleteTranslations(language: string): boolean
+```
+  
+Deletes the translation object for the given language.  
+Returns `true` if the object was deleted, or `false` if it couldn't be found.  
+  
+<details><summary><b>Example - click to view</b></summary>
 
-tr.addTranslations("de-CH", {
-  // overwrite the "greeting" but keep other keys as they are:
-  ...translation_de,
-  greeting: "Grüezi!",
-});
+```ts
+import { tr } from "@sv443-network/userutils";
 
-tr.addTranslations("de-AT", {
-  // overwrite "greeting" again but keep other keys as they are:
-  ...translation_de,
-  greeting: "Grüß Gott!",
+tr.addTranslations("en", {
+  hello: "Hello, World!",
 });
 
-// example for custom pluralization using a predefined suffix:
+tr.for("en", "hello"); // "Hello, World!"
 
-tr.addTranslations("en", {
-  "cart_items_added-0": "No items were added to the cart",
-  "cart_items_added-1": "Added %1 item to the cart",
-  "cart_items_added-n": "Added %1 items to the cart",
-});
+tr.deleteTranslations("en");
 
-/** A number or any object with a length or size property */
-type Numberish = number | Array<unknown> | NodeList | { length: number } | { size: number };
-
-/**
- * Returns the translated value given the key with a common pluralization identifier appended to it,  
- * given the number of items (or size of Array/NodeList or anything else with a `length` or `size` property).
- */
-function trpl(key: string, num: Numberish, ...values: Stringifiable[]): string {
-  if(typeof num !== "number") {
-    if("length" in num)
-      num = num.length;
-    else if("size" in num)
-      num = num.size;
-  }
+// returns the key itself:
+tr.for("en", "hello"); // "hello"
+```
+</details>
+
+<br>
+
+### tr.setFallbackLanguage()
+Usage:  
+```ts
+tr.setFallbackLanguage(language: string | undefined): void
+```
+  
+Sets the fallback language to be used when a key is not found.  
+If `undefined` is passed, the fallback language will be disabled (default behavior).  
+  
+<details><summary><b>Example - click to view</b></summary>
 
-  let plKey = key;
-  if(num === 0)
-    plKey = `${key}-0`;
-  else if(num === 1)
-    plKey = `${key}-1`;
-  else
-    plKey = `${key}-n`; // will be the fallback for everything like non-numeric values or NaN
+```ts
+import { tr } from "@sv443-network/userutils";
 
-  return tr(plKey, ...values);
+const trEn = {
+  hello: "Hello, World!",
+  goodbye: "Goodbye, World!",
 };
 
-// this has to be called once for tr("key") to work - otherwise you can use tr.forLang("en", "key")
-tr.setLanguage("en");
+const trDe = {
+  hello: "Hallo, Welt!",
+};
 
-const items = [];
-console.log(trpl("cart_items_added", items, items.length)); // "No items were added to the cart"
+tr.addTranslations("en", trEn);
+tr.addTranslations("de", trDe);
 
-items.push("foo");
-console.log(trpl("cart_items_added", items, items.length)); // "Added 1 item to the cart"
+tr.setFallbackLanguage("en");
 
-items.push("bar");
-console.log(trpl("cart_items_added", items, items.length)); // "Added 2 items to the cart"
+// "en" should always have the most up-to-date keys, so it is used for the generic parameter:
+const t = tr.use<TrKeys<typeof trEn>>("de");
 
-// if you run across cases like this, you need to modify your implementation of `trpl()` accordingly:
-const someVal = parseInt("not a number");
-console.log(trpl("cart_items_added", someVal, someVal)); // "Added NaN items to the cart"
+t("hello"); // "Hallo, Welt!"
+// doesn't exist, so falls back to "en":
+t("goodbye"); // "Goodbye, World!"
 ```
 </details>
 
 <br>
 
-### tr.getTranslations()
+### tr.getFallbackLanguage()
 Usage:  
 ```ts
-tr.getTranslations(language?: string): Record<string, string | object> | undefined
-```  
+tr.getFallbackLanguage(): string | undefined
+```
   
-Returns the translations of the specified language.  
-If no language is specified, it will return the translations of the currently active language set by [`tr.setLanguage()`](#trsetlanguage)  
-If no translations are found, it will return undefined.  
+Returns the currently set fallback language, or `undefined` if no fallback language was set.  
   
 <details><summary><b>Example - click to view</b></summary>
 
 ```ts
 import { tr } from "@sv443-network/userutils";
 
-tr.addTranslations("en", {
-  welcome: "Welcome",
-});
+tr.getFallbackLanguage(); // undefined
 
-console.log(tr.getTranslations());     // undefined
-tr.setLanguage("en");
-console.log(tr.getTranslations());     // { "welcome": "Welcome" }
+tr.setFallbackLanguage("en");
 
-console.log(tr.getTranslations("en")); // { "welcome": "Welcome" }
-
-console.log(tr.getTranslations("de")); // undefined
+tr.getFallbackLanguage(); // "en"
 ```
 </details>
 
 <br>
 
-### tr.deleteTranslations()
+### tr.addTransform()
 Usage:  
 ```ts
-tr.deleteTranslations(language?: string): void
+addTransform<TTrKey extends string = string>(transform: [RegExp, TransformFn<TTrKey>]): void
 ```
   
-Deletes the translations of the specified language.  
-If no language is given, tries to use the currently active language set by [`tr.setLanguage()`](#trsetlanguage)  
+Registers a transformation pattern and function for argument insertion or miscellaneous preprocessing.  
+The transforms will be applied in the order they were added, so you can easily chain multiple transformations.  
+  
+The pattern should be a regular expression that matches the desired format in the translation strings.  
+The function should return the transformed string synchronously and will be called with a single object parameter that has the following properties:  
+| Property | Type | Description |
+| :-- | :-- | :-- |
+| `language` | string | The current or fallback language - empty string if both are not set |
+| `matches` | RegExpExecArray | All matches as returned by `RegExp.exec()` |
+| `trKey` | TTrKey | The translation key |
+| `trValue` | string | The translation value before any transformations |
+| `currentValue` | string | The current value, possibly in-between transformations |
+| `trArgs` | (Stringifiable \| Record<string, Stringifiable>)[] | The arguments that were passed to the translation function |
+  
+The generic `TTrKey` can be used to enforce type safety for the keys.  
+You can pass the result of the generic type [`TrKeys`](#trkeys) to easily generate a union type of all keys in the given translation object.  
   
 <details><summary><b>Example - click to view</b></summary>
 
@@ -2564,147 +2551,170 @@ If no language is given, tries to use the currently active language set by [`tr.
 import { tr } from "@sv443-network/userutils";
 
 tr.addTranslations("en", {
-  welcome: "Welcome",
+  templateLiteral: "Hello, ${name}!\nYou have ${notifs} notifications.",
+  percent: "Hello, %1!\nYou have %2 notifications.",
+  profanity: "Damn, that's a lot of God damn notifications!",
+  markup: "<c=#ff0000>This is red</c> and <c=#0f0>this is green.</c>",
 });
 
-tr.addTranslations("de", {
-  welcome: "Willkommen",
-});
+const t = tr.use("en");
+
+
+// using the templateLiteral transform:
+
+tr.addTransform(tr.transforms.templateLiteral);
+
+// both of these are equivalent:
+t("templateLiteral", { name: "John", notifs: 42 });  // "Hello, John!\nYou have 42 notifications."
+t("templateLiteral", "John", 42);                    // "Hello, John!\nYou have 42 notifications."
+
+// if the first argument is an object and implements toString(), positional insertion will be used:
+t("templateLiteral", { toString: () => "John"}, 42); // "Hello, John!\nYou have 42 notifications."
+
+
+// using the percent transform:
 
-console.log(tr.getTranslations("en")); // { "welcome": "Welcome" }
+tr.addTransform(tr.transforms.percent);
 
-tr.setLanguage("en");
+// objects will be stringified and inserted positionally:
+t("percent", { toString: () => "John" }, 42); // "Hello, John!\nYou have 42 notifications."
+t("percent", {}, {});                         // "Hello, [object Object]!\nYou have [object Object] notifications."
 
-// no language code needed anymore because of tr.setLanguage("en"):
-tr.deleteTranslations();
 
-console.log(tr.getTranslations()); // undefined
+// custom transform for a very rudimentary profanity filter:
+
+tr.addTransform([
+  /damn/gmi,
+  ({ trValue }) => trValue.replace(/damn/gm, "darn").replace(/Damn/gm, "Darn"),
+]);
+
+t("profanity"); // "Darn, that's a lot of God darn notifications!"
+
+
+// custom transform for simple markup text coloration using HTML in the format <c=#hex>text</c>:
+
+tr.addTransform([
+  /<c=#((?:[0-9a-f]{3}|[0-9a-f]{6}))>(.*?)<\/c>/gmi,
+  ({ matches }) => `<span style="color: #${matches[1]};">${matches[2] ?? ""}</span>`,
+]);
+
+t("markup"); // "<span style="color: #ff0000;">This is red</span> and <span style="color: #0f0;">this is green.</span>"
 ```
 </details>
 
 <br>
 
-### tr.hasKey()
+### tr.deleteTransform()
 Usage:  
 ```ts
-tr.hasKey(key: string, language?: string): boolean
+deleteTransform(patternOrFn: RegExp | string | TransformFn): boolean
 ```
   
-Checks if the specified key exists in the translations of the specified language.  
-If no language is given, tries to use the currently active language set by [`tr.setLanguage()`](#trsetlanguage)  
+Deletes a transformation based on the given pattern or regex or function reference.  
+Returns `true` if the transformation was found and deleted, else `false`.  
+If the given pattern is of type `string`, it will be compared to the regexes' `source` property.  
   
 <details><summary><b>Example - click to view</b></summary>
 
 ```ts
 import { tr } from "@sv443-network/userutils";
 
-tr.addTranslations("en", {
-  welcome: "Welcome",
-});
+const myMarkupTransform = [
+  /<c=#((?:[0-9a-f]{3}|[0-9a-f]{6}))>(.*?)<\/c>/gmi,
+  ({ matches }) => `<span style="color: #${matches[1]};">${matches[2] ?? ""}</span>`,
+] satisfies TransformTuple;
 
-tr.setLanguage("en");
+tr.addTransform(myMarkupTransform);
 
-console.log(tr.hasKey("welcome"));       // true
-console.log(tr.hasKey("foo"));           // false
-console.log(tr.hasKey("welcome", "de")); // false
+// any of these will work:
+tr.deleteTransform(myMarkupTransform[0]);
+tr.deleteTransform(myMarkupTransform[1]);
+tr.deleteTransform("<c=#((?:[0-9a-f]{3}|[0-9a-f]{6}))>(.*?)<\\/c>");
 ```
 </details>
 
 <br>
 
-### tr.addTransform()
-Usage:  
-```ts
-tr.addTransform(pattern: RegExp | string, fn: (matches: RegExpMatchArray, language: string) => Stringifiable): void
-```
-  
-Registers a transformation function that will be called on every translation that matches the specified regexp pattern. You can also pass a simple string that will be converted to a RegExp object with the `gm` flags.  
-After all %n-formatted values have been injected, the transform functions will be called sequentially in the order they were added.  
-Each function will receive the RegExpMatchArray [see MDN](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/match) and the current language as arguments.  
+### tr.transforms
+This object contains some predefined transformation functions that can be used to quickly set up argument insertion.  
   
+Currently available transforms:
+| Key | Pattern | Type(s) |
+| :-- | :-- | :-- |
+| `templateLiteral` | `${key}` | Keyed / Positional |
+| `percent` | `%n` | Positional |
+
 <details><summary><b>Example - click to view</b></summary>
 
 ```ts
 import { tr } from "@sv443-network/userutils";
 
 tr.addTranslations("en", {
-  "greeting": {
-    "hello": "Hello",
-  },
-  "welcome": "Welcome, <$USERNAME>",
-  "welcome_headline_html": "Welcome, <$USERNAME>\n<col=orangered>You have %1 unread notifications.</col>\n<col=#128923>You have %2 unread messages.</col>",
+  templateLiteral: "Hello, ${name}!\nYou have ${notifs} notifications.",
+  percent: "Hello, %1!\nYou have %2 notifications.",
 });
 
-tr.setLanguage("en");
+const t = tr.use("en");
 
-// generic transform to inject global values on demand:
-tr.addTransform(/<\$([A-Z_]+)>/gm, (matches) => {
-  switch(matches[1]) {
-    default: return matches[0];
-    // retrieved from somewhere else in the app:
-    case "USERNAME": return "John";
-  }
-});
 
-// match <col=red>...</col> and replace it with an HTML span tag:
-tr.addTransform(/<col=([a-z]+)>(.*?)<\/col>/gm, (matches) => {
-  return `<span style="color: ${matches[1]};">${matches[2]}</span>`;
-});
+// using the templateLiteral transform:
+
+tr.addTransform(tr.transforms.templateLiteral);
+
+// both of these are equivalent:
+t("templateLiteral", { name: "John", notifs: 42 });  // "Hello, John!\nYou have 42 notifications."
+t("templateLiteral", "John", 42);                    // "Hello, John!\nYou have 42 notifications."
+
+// if the first argument is an object and implements toString(), positional insertion will be used:
+t("templateLiteral", { toString: () => "John"}, 42); // "Hello, John!\nYou have 42 notifications."
 
-// replace all occurrences of "Welcome" with "Hello" and make sure this works across all languages:
-tr.addTransform("Welcome", (_matches, language) => tr.forLang(language, "greeting.hello"));
-tr.addTransform("welcome", (_matches, language) => tr.forLang(language, "greeting.hello").toLowerCase());
 
-console.log(tr("welcome")); // "Hello, John"
+// using the percent transform:
 
-console.log(tr("welcome_headline_html", 3, 5));
-// `Hello, John\n
-// <span style="color: orangered;">You have 3 unread notifications.</span>\n
-// <span style="color: #128923;">You have 5 unread messages.</span>`
+tr.addTransform(tr.transforms.percent);
+
+// objects will be stringified and inserted positionally:
+t("percent", { toString: () => "John" }, 42); // "Hello, John!\nYou have 42 notifications."
+t("percent", {}, {});                         // "Hello, [object Object]!\nYou have [object Object] notifications."
 ```
+
 </details>
 
 <br>
 
-### tr.deleteTransform()
+### TrKeys
 Usage:  
 ```ts
-tr.deleteTransform(patternOrFn: RegExp | string | ((matches: RegExpMatchArray, language: string) => Stringifiable)): void
+type MyKeys = TrKeys<TrObject>
 ```
   
-Deletes the transformation function that matches the specified pattern or function.  
-If the function is not found, nothing will happen.  
+This type is used to generate a union type of all keys in a given translation object.  
+Nested keys will be joined with a dot (`.`) to form the final key.  
   
 <details><summary><b>Example - click to view</b></summary>
 
 ```ts
-import { tr } from "@sv443-network/userutils";
+import { tr, type TrKeys } from "@sv443-network/userutils";
 
-tr.addTranslations("en", {
-  "welcome": "Welcome, <$USERNAME>",
-});
-
-tr.setLanguage("en");
-
-// generic transform to inject global values on demand:
-const globalValTransform = (matches) => {
-  switch(matches[1]) {
-    default: return matches[0];
-    // retrieved from somewhere else in the app:
-    case "USERNAME": return "John";
-  }
+const trEn = {
+  hello: "Hello, World!",
+  nested: {
+    key: "This is a nested key",
+  },
+  "foo.bar": "This key isn't nested, it just has a dot",
 };
 
-tr.addTransform(/<\$([A-Z_]+)>/gm, globalValTransform);
+tr.addTranslations("en", trEn);
 
-console.log(tr("welcome")); // "Welcome, John"
+type MyKeysEn = TrKeys<typeof trEn>; // "hello" | "nested.key" | "foo.bar"
 
-tr.deleteTransform(globalValTransform);
-
-console.log(tr("welcome")); // "Welcome, <$USERNAME>"
+// full type safety and autocomplete:
+const t = tr.use<MyKeysEn>("en");
 ```
 </details>
 
+<!-- #region Colors -->
+
 <br><br>
 
 ## Colors:

+ 367 - 149
lib/translation.ts

@@ -1,31 +1,68 @@
-import { insertValues } from "./misc.js";
 import type { Stringifiable } from "./types.js";
 
+//#region types
+
 /**
  * Translation object to pass to {@linkcode tr.addTranslations()}  
  * Can be a flat object of identifier keys and the translation text as the value, or an infinitely nestable object containing the same.  
  *   
  * @example
-* // Flat object:
-* const tr_en: TrObject = {
-*   hello: "Hello, %1!",
-*   foo: "Foo",
-* };
-* 
-* // Nested object:
-* const tr_de: TrObject = {
-*   hello: "Hallo, %1!",
-*   foo: {
-*     bar: "Foo bar",
-*   },
-* };
-*/
+ * // Flat object:
+ * const tr_en: TrObject = {
+ *   hello: "Hello, %1!",
+ *   foo: "Foo",
+ * };
+ * 
+ * // Nested object:
+ * const tr_de: TrObject = {
+ *   hello: "Hallo, %1!",
+ *   foo: {
+ *     bar: "Foo bar",
+ *   },
+ * };
+ */
 export interface TrObject {
  [key: string]: string | TrObject;
 }
 
-/** Function that transforms a matched translation string into something else */
-export type TransformFn = (matches: RegExpMatchArray, language: string) => Stringifiable;
+/** Properties for the transform function that transforms a matched translation string into something else */
+export type TransformFnProps<TTrKey extends string = string> = {
+  /** The current language - empty string if not set yet */
+  language: string;
+  /** The matches as returned by `RegExp.exec()` */
+  matches: RegExpExecArray[];
+  /** The translation key */
+  trKey: TTrKey;
+  /** Translation value before any transformations */
+  trValue: string;
+  /** Current value, possibly in-between transformations */
+  currentValue: string;
+  /** Arguments passed to the translation function */
+  trArgs: (Stringifiable | Record<string, Stringifiable>)[];
+};
+
+/** Function that transforms a matched translation string into another string */
+export type TransformFn<TTrKey extends string = string> = (props: TransformFnProps<TTrKey>) => Stringifiable;
+
+/** Transform pattern and function in tuple form */
+export type TransformTuple<TTrKey extends string = string> = [RegExp, TransformFn<TTrKey>];
+
+/**
+ * Pass a recursive or flat translation object to this generic type to get all keys in the object.  
+ * @example ```ts
+ * type Keys = TrKeys<{ a: { b: "foo" }, c: "bar" }>;
+ * // result: type Keys = "a.b" | "c"
+ * ```
+ */
+export type TrKeys<TTrObj, P extends string = ""> = {
+  [K in keyof TTrObj]: K extends string | number | boolean | null | undefined
+    ? TTrObj[K] extends object
+      ? TrKeys<TTrObj[K], `${P}${K}.`>
+      : `${P}${K}`
+    : never
+}[keyof TTrObj];
+
+//#region vars
 
 /** All translations loaded into memory */
 const trans: {
@@ -35,28 +72,55 @@ const trans: {
 /** All registered value transformers */
 const valTransforms: Array<{
  regex: RegExp;
- fn: (matches: RegExpMatchArray, language: string) => Stringifiable;
+ fn: TransformFn;
 }> = [];
 
-/** Currently set language */
-let curLang = "";
+/** Fallback language - if undefined, the trKey itself will be returned if the translation is not found */
+let fallbackLang: string | undefined;
+
+//#region tr backend
 
-/** Common function to resolve the translation text in a specific language. */
-function translate(language: string, key: string, ...args: Stringifiable[]): string {
+/** Common function to resolve the translation text in a specific language and apply transform functions. */
+function translate<TTrKey extends string = string>(language: string, key: TTrKey, ...trArgs: (Stringifiable | Record<string, Stringifiable>)[]): string {
   if(typeof language !== "string")
-    language = curLang ?? "";
+    language = fallbackLang ?? "";
 
   const trObj = trans[language];
 
   if(typeof language !== "string" || language.length === 0 || typeof trObj !== "object" || trObj === null)
-    return key;
+    return fallbackLang ? translate(fallbackLang, key, ...trArgs) : key;
 
-  const transform = (value: string): string => {
-    const tf = valTransforms.find((t) => t.regex.test(value));
+  /** Apply all transforms that match the translation string */
+  const transformTrVal = (trKey: TTrKey, trValue: string): string => {
+    const tfs = valTransforms.filter(({ regex }) => new RegExp(regex).test(trValue));
 
-    return tf
-      ? value.replace(tf.regex, (...matches) => String(tf.fn(matches, language)))
-      : value;
+    if(tfs.length === 0)
+      return trValue;
+
+    let retStr = String(trValue);
+
+    for(const tf of tfs) {
+      const re = new RegExp(tf.regex);
+
+      const matches: RegExpExecArray[] = [];
+      let execRes: RegExpExecArray | null;
+      while((execRes = re.exec(trValue)) !== null) {
+        if(matches.some(m => m[0] === execRes?.[0]))
+          break;
+        matches.push(execRes);
+      }
+
+      retStr = String(tf.fn({
+        language,
+        trValue,
+        currentValue: retStr,
+        matches,
+        trKey,
+        trArgs,
+      }));
+    }
+
+    return retStr;
   };
 
   // try to resolve via traversal (e.g. `trObj["key"]["parts"]`)
@@ -68,161 +132,315 @@ function translate(language: string, key: string, ...args: Stringifiable[]): str
     value = value?.[part];
   }
   if(typeof value === "string")
-    return transform(insertValues(value, args));
+    return transformTrVal(key, value);
 
   // try falling back to `trObj["key.parts"]`
   value = trObj?.[key];
   if(typeof value === "string")
-    return transform(insertValues(value, args));
+    return transformTrVal(key, value);
 
-  // default to translation key
-  return key;
+  // default to fallbackLang or translation key
+  return fallbackLang ? translate(fallbackLang, key, ...trArgs) : key;
 }
 
+//#region tr funcs
+
 /**
-* Returns the translated text for the specified key in the current language set by {@linkcode tr.setLanguage()}  
-* Use {@linkcode tr.forLang()} to get the translation for a specific language instead of the currently set one.  
-* If the key is not found in the currently set language, the key itself is returned.  
-*   
-* - ⚠️ Remember to register a language with {@linkcode tr.addTranslations()} and set it as active with {@linkcode tr.setLanguage()} before using this function, otherwise it will always return the key itself.
-* @param key Key of the translation to return
-* @param args Optional arguments to be passed to the translated text. They will replace placeholders in the format `%n`, where `n` is the 1-indexed argument number
-*/
-const tr = (key: string, ...args: Stringifiable[]): string => translate(curLang, key, ...args);
+ * Returns the translated text for the specified key in the specified language.  
+ * If the key is not found in the specified previously registered translation, the key itself is returned.  
+ *   
+ * ⚠️ Remember to register a language with {@linkcode tr.addTranslations()} before using this function, otherwise it will always return the key itself.
+ * @param language Language code or name to use for the translation
+ * @param key Key of the translation to return
+ * @param args Optional arguments to be passed to the translated text. They will replace placeholders in the format `%n`, where `n` is the 1-indexed argument number
+ */
+function trFor<TTrKey extends string = string>(language: string, key: TTrKey, ...args: (Stringifiable | Record<string, Stringifiable>)[]): string {
+  const txt = translate(language, key, ...args);
+  if(txt === key)
+    return fallbackLang
+      ? translate(fallbackLang, key, ...args)
+      : key;
+  return txt;
+}
 
 /**
-* Returns the translated text for the specified key in the specified language.  
-* If the key is not found in the specified previously registered translation, the key itself is returned.  
-*   
-* - ⚠️ Remember to register a language with {@linkcode tr.addTranslations()} before using this function, otherwise it will always return the key itself.
-* @param language Language code or name to use for the translation
-* @param key Key of the translation to return
-* @param args Optional arguments to be passed to the translated text. They will replace placeholders in the format `%n`, where `n` is the 1-indexed argument number
-*/
-tr.forLang = translate;
+ * Prepares a translation function for a specific language.
+ * @example ```ts
+ * tr.addTranslations("en", {
+ *   hello: "Hello, %1!",
+ * });
+ * const t = tr.useTr("en");
+ * t("hello", "John"); // "Hello, John!"
+ * ```
+ */
+function useTr<TTrKey extends string = string>(language: string) {
+  return (key: TTrKey, ...args: (Stringifiable | Record<string, Stringifiable>)[]) =>
+    translate<TTrKey>(language, key, ...args);
+}
 
 /**
-* Registers a new language and its translations - if the language already exists, it will be overwritten.  
-* The translations are a key-value pair where the key is the translation key and the value is the translated text.  
-*   
-* The translations can contain placeholders in the format `%n`, where `n` is the 1-indexed argument number.  
-* These placeholders will be replaced by the arguments passed to the translation functions.  
-* @param language Language code or name to register
-* @param translations Translations for the specified language
-* @example ```ts
-* tr.addTranslations("en", {
-*   hello: "Hello, %1!",
-*   foo: {
-*     bar: "Foo bar",
-*   },
-* });
-* ```
-*/
-tr.addTranslations = (language: string, translations: TrObject): void => {
+ * Checks if a translation exists given its {@linkcode key} in the specified {@linkcode language} or the set fallback language.  
+ * If the given language was not registered with {@linkcode tr.addTranslations()}, this function will return `false`.  
+ * @param key Key of the translation to check for
+ * @param language Language code or name to check in - defaults to the currently active language (set by {@linkcode tr.setLanguage()})
+ * @returns Whether the translation key exists in the specified language - always returns `false` if no language is given and no active language was set
+ */
+function hasKey<TTrKey extends string = string>(language = fallbackLang ?? "", key: TTrKey): boolean {
+  return tr.for(language, key) !== key;
+}
+
+//#region manage translations
+
+/**
+ * Registers a new language and its translations - if the language already exists, it will be overwritten.  
+ * The translations are a key-value pair where the key is the translation key and the value is the translated text.  
+ * The translations can also be infinitely nested objects, resulting in a dot-separated key.
+ * @param language Language code or name to register - I recommend sticking to a standard like [ISO 639](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes) or [BCP 47](https://en.wikipedia.org/wiki/IETF_language_tag)
+ * @param translations Translations for the specified language
+ * @example ```ts
+ * tr.addTranslations("en", {
+ *   hello: "Hello, %1!",
+ *   foo: {
+ *     bar: "Foo bar",
+ *   },
+ * });
+ * ```
+ */
+function addTranslations(language: string, translations: TrObject): void {
   trans[language] = JSON.parse(JSON.stringify(translations));
-};
+}
 
 /**
-* Sets the active language for the translation functions.  
-* This language will be used by the {@linkcode tr()} function to return the translated text.  
-* If the language is not registered with {@linkcode tr.addTranslations()}, the translation functions will always return the key itself.  
-* @param language Language code or name to set as active
-*/
-tr.setLanguage = (language: string): void => {
-  curLang = language;
+ * Returns the translation object for the specified language or currently active one.  
+ * If the language is not registered with {@linkcode tr.addTranslations()}, this function will return `undefined`.  
+ * @param language Language code or name to get translations for - defaults to the currently active language (set by {@linkcode tr.setLanguage()})
+ * @returns Translations for the specified language
+ */
+function getTranslations(language = fallbackLang ?? ""): TrObject | undefined {
+  return trans[language];
+}
+
+/**
+ * Deletes the translations for the specified language from memory.  
+ * @param language Language code or name to delete
+ * @returns Whether the translations for the passed language were successfully deleted
+ */
+const deleteTranslations = (language: string): boolean => {
+  if(language in trans) {
+    delete trans[language];
+    return true;
+  }
+  return false;
 };
 
+//#region set fb lang
+
 /**
-* Returns the active language set by {@linkcode tr.setLanguage()}  
-* If no language is set, this function will return `undefined`.  
-* @returns Active language code or name
-*/
-tr.getLanguage = (): string => curLang;
+ * The fallback language to use when a translation key is not found in the currently active language.  
+ * Leave undefined to disable fallbacks and just return the translation key if translations are not found.
+ */
+function setFallbackLanguage(fallbackLanguage?: string): void {
+  fallbackLang = fallbackLanguage;
+}
+
+/** Returns the fallback language set by {@linkcode tr.setFallbackLanguage()} */
+function getFallbackLanguage(): string | undefined {
+  return fallbackLang;
+}
+
+//#region transforms
 
 /**
-* Returns the translation object for the specified language or currently active one.  
-* If the language is not registered with {@linkcode tr.addTranslations()}, this function will return `undefined`.  
-* @param language Language code or name to get translations for - defaults to the currently active language (set by {@linkcode tr.setLanguage()})
-* @returns Translations for the specified language
-*/
-tr.getTranslations = (language = curLang): TrObject | undefined => trans[language];
+ * Adds a transform function that gets called after resolving a translation for any language.  
+ * Use it to enable dynamic values in translations, for example to insert custom global values from the application or to denote a section that could be encapsulated by rich text.  
+ * Each function will receive the RegExpMatchArray [see MDN](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/match) and the current language as arguments.  
+ * After all %n-formatted values have been injected, the transform functions will be called sequentially in the order they were added.
+ * @example
+ * ```ts
+ * tr.addTranslations("en", {
+ *    "greeting": {
+ *      "with_username": "Hello, ${USERNAME}",
+ *      "headline_html": "Hello, ${USERNAME}<br><c=red>You have ${UNREAD_NOTIFS} unread notifications.</c>"
+ *    }
+ * });
+ * 
+ * // replace ${PATTERN}
+ * tr.addTransform(/<\$([A-Z_]+)>/g, ({ matches }) => {
+ *   switch(matches?.[1]) {
+ *     default: return "<UNKNOWN_PATTERN>";
+ *     // these would be grabbed from elsewhere in the application:
+ *     case "USERNAME": return "JohnDoe45";
+ *     case "UNREAD_NOTIFS": return 5;
+ *   }
+ * });
+ * 
+ * // replace <c=red>...</c> with <span class="color red">...</span>
+ * tr.addTransform(/<c=([a-z]+)>(.*?)<\/c>/g, ({ matches }) => {
+ *   const color = matches?.[1];
+ *   const content = matches?.[2];
+ * 
+ *   return "<span class=\"color " + color + "\">" + content + "</span>";
+ * });
+ * 
+ * tr.setLanguage("en");
+ * 
+ * tr("greeting.with_username"); // "Hello, JohnDoe45"
+ * tr("greeting.headline"); // "<b>Hello, JohnDoe45</b>\nYou have 5 unread notifications."
+ * ```
+ * @param args A tuple containing the regular expression to match and the transform function to call if the pattern is found in a translation string
+ */
+function addTransform<TTrKey extends string = string>(transform: TransformTuple<TTrKey>): void {
+  const [pattern, fn] = transform;
+  valTransforms.push({
+    fn: fn as TransformFn,
+    regex: typeof pattern === "string"
+      ? new RegExp(pattern, "gm")
+      : pattern
+  });
+}
 
 /**
-* Deletes the translations for the specified language from memory.  
-* @param language Language code or name to delete
-*/
-tr.deleteTranslations = (language = curLang): void => {
-  delete trans[language];
-};
+ * Deletes the first transform function from the list of registered transform functions.  
+ * @param patternOrFn A reference to the regular expression of the transform function, a string matching the original pattern, or a reference to the transform function to delete
+ * @returns Returns true if the transform function was found and deleted, false if it wasn't found
+ */
+function deleteTransform(patternOrFn: RegExp | string | TransformFn): boolean {
+  const idx = valTransforms.findIndex((t) =>
+    typeof patternOrFn === "function"
+      ? t.fn === patternOrFn
+      : (
+        typeof patternOrFn === "string"
+          ? t.regex.source === patternOrFn
+          : t.regex === patternOrFn
+      )
+  );
+  if(idx !== -1) {
+    valTransforms.splice(idx, 1);
+    return true;
+  }
+  return false;
+}
+
+//#region predef transforms
 
 /**
-* Checks if a translation exists given its {@linkcode key} in the specified {@linkcode language} or else the currently active one.  
-* If the language is not registered with {@linkcode tr.addTranslations()}, this function will return `false`.  
-* @param key Key of the translation to check for
-* @param language Language code or name to check in - defaults to the currently active language (set by {@linkcode tr.setLanguage()})
-* @returns Whether the translation key exists in the specified language - always returns `false` if no language is given and no active language was set
-*/
-tr.hasKey = (key: string, language = curLang): boolean => {
-  return tr.forLang(language, key) !== key;
-};
+ * This transform will replace placeholders matching `${key}` with the value of the passed argument(s).  
+ * The arguments can be passed in keyed object form or positionally via the spread operator:
+ * - Keyed: If the first argument is an object and `key` is found in it, the value will be used for the replacement.
+ * - Positional: If the first argument is not an object or has a `toString()` method that returns something that doesn't start with `[object`, the values will be positionally inserted in the order they were passed.
+ *   
+ * @example ```ts
+ * tr.addTranslations("en", {
+ *  "greeting": "Hello, ${user}!\nYou have ${notifs} notifications.",
+ * });
+ * tr.addTransform(tr.transforms.templateLiteral);
+ * 
+ * const t = tr.use("en");
+ * 
+ * // both calls return the same result:
+ * t("greeting", { user: "John", notifs: 5 }); // "Hello, John!\nYou have 5 notifications."
+ * t("greeting", "John", 5);                   // "Hello, John!\nYou have 5 notifications."
+ * 
+ * // when a key isn't found in the object, it will be left as-is:
+ * t("greeting", { user: "John" }); // "Hello, John!\nYou have ${notifs} notifications."
+ * ```
+ */
+const templateLiteralTransform = [
+  /\$\{([a-zA-Z0-9$_-]+)\}/gm,
+  ({ matches, trArgs, trValue }) => {
+    const patternStart = "${",
+      patternEnd = "}",
+      patternRegex = /\$\{.+\}/m;
+
+    let str = String(trValue);
+
+    const eachKeyInTrString = (keys: string[]) => keys.every((key) => trValue.includes(`${patternStart}${key}${patternEnd}`));
+
+    const namedMapping = () => {
+      if(!str.includes(patternStart) || typeof trArgs[0] === "undefined" || typeof trArgs[0] !== "object" || !eachKeyInTrString(Object.keys(trArgs[0] ?? {})))
+        return;
+      for(const match of matches) {
+        const repl = match[1] !== undefined ? (trArgs[0] as Record<string, string>)[match[1]] : undefined;
+        if(typeof repl !== "undefined")
+          str = str.replace(match[0], String(repl));
+      }
+    };
+
+    const positionalMapping = () => {
+      if(!(patternRegex.test(str)) || !trArgs[0])
+        return;
+      let matchNum = -1;
+      for(const match of matches) {
+        matchNum++;
+        if(typeof trArgs[matchNum] !== "undefined")
+          str = str.replace(match[0], String(trArgs[matchNum]));
+      }
+    };
+
+    /** Whether the first args parameter is an object that doesn't implement a custom `toString` method */
+    const isArgsObject = trArgs[0] && typeof trArgs[0] === "object" && trArgs[0] !== null && String(trArgs[0]).startsWith("[object");
+
+    if(isArgsObject && eachKeyInTrString(Object.keys(trArgs[0]!)))
+      namedMapping();
+    else
+      positionalMapping();
+
+    return str;
+  },
+] as const satisfies TransformTuple<string>;
 
 /**
-* Adds a transform function that gets called after resolving a translation for any language.  
-* Use it to enable dynamic values in translations, for example to insert custom global values from the application or to denote a section that could be encapsulated by rich text.  
-* Each function will receive the RegExpMatchArray [see MDN](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/match) and the current language as arguments.  
-* After all %n-formatted values have been injected, the transform functions will be called sequentially in the order they were added.
-* @example
-* ```ts
+ * This transform will replace `%n` placeholders with the value of the passed arguments.  
+ * The `%n` placeholders are 1-indexed, meaning `%1` will be replaced by the first argument, `%2` by the second, and so on.  
+ * Objects will be stringified via `String()` before being inserted.  
+ *   
+ * @example ```ts
 * tr.addTranslations("en", {
-*    "greeting": {
-*      "with_username": "Hello, <$USERNAME>",
-*      "headline_html": "Hello, <$USERNAME><br><c=red>You have <$UNREAD_NOTIFS> unread notifications.</c>"
-*    }
+*  "greeting": "Hello, %1!\nYou have %2 notifications.",
 * });
+* tr.addTransform(tr.transforms.percent);
 * 
-* // replace <$PATTERN>
-* tr.addTransform(/<\$([A-Z_]+)>/g, (matches: RegExpMatchArray, language: string) => {
-*   switch(matches?.[1]) {
-*     default: return "<UNKNOWN_PATTERN>";
-*     // these would be grabbed from elsewhere in the application:
-*     case "USERNAME": return "JohnDoe45";
-*     case "UNREAD_NOTIFS": return 5;
-*   }
-* });
+* const t = tr.use("en");
 * 
-* // replace <c=red>...</c> with <span class="color red">...</span>
-* tr.addTransform(/<c=([a-z]+)>(.*?)<\/c>/g, (matches: RegExpMatchArray, language: string) => {
-*   const color = matches?.[1];
-*   const content = matches?.[2];
-* 
-*   return "<span class=\"color " + color + "\">" + content + "</span>";
-* });
+* // arguments are inserted in the order they're passed:
+* t("greeting", "John", 5); // "Hello, John!\nYou have 5 notifications."
 * 
-* tr.setLanguage("en");
-* 
-* tr("greeting.with_username"); // "Hello, JohnDoe45"
-* tr("greeting.headline"); // "<b>Hello, JohnDoe45</b>\nYou have 5 unread notifications."
+* // when a value isn't found, the placeholder will be left as-is:
+* t("greeting", "John"); // "Hello, John!\nYou have %2 notifications."
 * ```
-* @param pattern Regular expression or string (passed to `new RegExp(pattern, "gm")`) that should match the entire pattern that calls the transform function
 */
-tr.addTransform = (pattern: RegExp | string, fn: TransformFn): void => {
-  valTransforms.push({
-    regex: typeof pattern === "string" ? new RegExp(pattern, "gm") : pattern,
-    fn,
-  });
-};
+const percentTransform = [
+  /\$\{([a-zA-Z0-9$_-]+)\}/gm,
+  ({ matches, trArgs, trValue }) => {
+    let str = String(trValue);
 
-/**
-* Deletes the first transform function from the list of registered transform functions.  
-* @param patternOrFn A reference to the regular expression of the transform function, a string matching the original pattern, or a reference to the transform function to delete
-*/
-tr.deleteTransform = (patternOrFn: RegExp | string | TransformFn): void => {
-  const idx = typeof patternOrFn === "function"
-    ? valTransforms.findIndex((t) => t.fn === patternOrFn)
-    : valTransforms.findIndex((t) => t.regex === (typeof patternOrFn === "string" ? new RegExp(patternOrFn, "gm") : patternOrFn));
+    for(const match of matches) {
+      const repl = match[1] !== undefined ? (trArgs[0] as Record<string, string>)[match[1]] : undefined;
+      if(typeof repl !== "undefined")
+        str = str.replace(match[0], String(repl));
+    }
 
-  if(idx !== -1)
-    valTransforms.splice(idx, 1);
+    return str;
+  },
+] as const satisfies TransformTuple<string>;
+
+//#region exports
+
+const tr = {
+  for: <TTrKey extends string = string>(...params: Parameters<typeof trFor<TTrKey>>) => trFor<TTrKey>(...params as Parameters<typeof trFor<TTrKey>>),
+  use: <TTrKey extends string = string>(...params: Parameters<typeof useTr<TTrKey>>) => useTr<TTrKey>(...params as Parameters<typeof useTr<TTrKey>>),
+  hasKey: <TTrKey extends string = string>(language = fallbackLang ?? "", key: TTrKey) => hasKey<TTrKey>(language, key),
+  addTranslations,
+  getTranslations,
+  deleteTranslations,
+  setFallbackLanguage,
+  getFallbackLanguage,
+  addTransform,
+  deleteTransform,
+  transforms: {
+    templateLiteral: templateLiteralTransform,
+    percent: percentTransform,
+  },
 };
 
 export { tr };