Ver Fonte

feat: reworked translation system

Sv443 há 4 meses atrás
pai
commit
9abfc6b
5 ficheiros alterados com 393 adições e 117 exclusões
  1. 11 0
      .changeset/beige-dots-refuse.md
  2. 5 1
      README-summary.md
  3. 206 42
      README.md
  4. 1 0
      lib/misc.ts
  5. 170 74
      lib/translation.ts

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

@@ -0,0 +1,11 @@
+---
+"@sv443-network/userutils": major
+---
+
+Reworked translation system:
+- **BREAKING:** Renamed function `tr.addLanguage()` to `tr.addTranslations()`
+- 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

+ 5 - 1
README-summary.md

@@ -72,10 +72,14 @@ or view the documentation of previous major releases:
 - **Translation:**
     - [`tr()`](https://github.com/Sv443-Network/UserUtils#tr) - simple JSON-based translation system with placeholder and nesting support
     - [`tr.forLang()`](https://github.com/Sv443-Network/UserUtils#trforlang) - translate with the specified language instead of the currently active one
-    - [`tr.addLanguage()`](https://github.com/Sv443-Network/UserUtils#traddlanguage) - add a language and its translations
     - [`tr.setLanguage()`](https://github.com/Sv443-Network/UserUtils#trsetlanguage) - set the currently active language for translations
     - [`tr.getLanguage()`](https://github.com/Sv443-Network/UserUtils#trgetlanguage) - returns the currently active language
+    - [`tr.addTranslations()`](https://github.com/Sv443-Network/UserUtils#traddtranslations) - add a language and its translations
     - [`tr.getTranslations()`](https://github.com/Sv443-Network/UserUtils#trgettranslations) - returns the translations for the given language or the currently active one
+    - [`tr.deleteTranslations()`](https://github.com/Sv443-Network/UserUtils#trdeletetranslations) - delete the translations for the given language or the active one
+    - [`tr.hasKey()`](https://github.com/Sv443-Network/UserUtils#trhaskey) - check if a translation key exists for the given or active language
+    - [`tr.addTransform()`](https://github.com/Sv443-Network/UserUtils#traddtransform) - add a transformation function to dynamically modify the translation value
+    - [`tr.deleteTransform()`](https://github.com/Sv443-Network/UserUtils#trdeletetransform) - delete a transformation function that was previously added
 - **Colors:**
     - [`hexToRgb()`](https://github.com/Sv443-Network/UserUtils#hextorgb) - convert a hex color string to an RGB or RGBA value tuple
     - [`rgbToHex()`](https://github.com/Sv443-Network/UserUtils#rgbtohex) - convert RGB or RGBA values to a hex color string

+ 206 - 42
README.md

@@ -77,10 +77,14 @@ View the documentation of previous major releases:
   - [**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.addLanguage()`](#traddlanguage) - add a language and its translations
     - [`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
   - [**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
@@ -2258,7 +2262,7 @@ 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.addLanguage()`](#traddlanguage) for an example on how this might be done.
+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>
 
@@ -2268,7 +2272,7 @@ Usage:
 tr(key: string, ...insertValues: Stringifiable[]): string
 ```
   
-The function returns the translation of the passed key in the language added by [`tr.addLanguage()`](#traddlanguage) and set by [`tr.setLanguage()`](#trsetlanguage)  
+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.
   
@@ -2285,20 +2289,22 @@ You could also write a wrapper function that can then return a default value or
 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.  
+  
 <details><summary><b>Example - click to view</b></summary>
 
 ```ts
 import { tr } from "@sv443-network/userutils";
 
 // add languages and translations:
-tr.addLanguage("en", {
+tr.addTranslations("en", {
   welcome: {
     generic: "Welcome",
     with_name: "Welcome, %1",
   },
 });
 
-tr.addLanguage("de", {
+tr.addTranslations("de", {
   welcome: {
     generic: "Willkommen",
     with_name: "Willkommen, %1",
@@ -2341,11 +2347,11 @@ This function does not change the currently active language set by [`tr.setLangu
 ```ts
 import { tr } from "@sv443-network/userutils";
 
-tr.addLanguage("en", {
+tr.addTranslations("en", {
   "welcome_name": "Welcome, %1",
 });
 
-tr.addLanguage("de", {
+tr.addTranslations("de", {
   "welcome_name": "Willkommen, %1",
 });
 
@@ -2360,19 +2366,44 @@ console.log(tr.forLang("de", "welcome_name", "John")); // "Willkommen, John"
 
 <br>
 
-### tr.addLanguage()
+### tr.setLanguage()
+Usage:  
+```ts
+tr.setLanguage(language: string): void
+```
+
+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>
+
+### tr.getLanguage()
 Usage:  
 ```ts
-tr.addLanguage(language: string, translations: Record<string, string | object>): void
+tr.getLanguage(): string | undefined
 ```
 
-Adds or overwrites a language and its associated translations to the translation function.  
+Returns the currently active language set by [`tr.setLanguage()`](#trsetlanguage)  
+If no language has been set yet, it will return undefined.
+
+<br>
+
+### tr.addTranslations()
+Usage:  
+```ts
+tr.addTranslations(language: string, translations: Record<string, string | object>): 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.addLanguage()` is called multiple times with the same language, the previous translations of that language will be overwritten.  
+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()`  
+These can be used to inject values into the translation when calling [`tr()`](#tr)  
   
 <details><summary><b>Example - click to view</b></summary>
 
@@ -2381,13 +2412,13 @@ import { tr, type Stringifiable } from "@sv443-network/userutils";
 
 // add a language with associated translations:
 
-tr.addLanguage("en", {
+tr.addTranslations("en", {
   lang_name: "Eglis", // no worries, the example below will overwrite this value
 });
 
 // overwriting previous translation, now with nested objects and placeholders:
 
-tr.addLanguage("en", {
+tr.addTranslations("en", {
   // to get this value, you could call `tr.forLang("en", "lang_name")`
   lang_name: "English",
   home_page: {
@@ -2402,12 +2433,12 @@ tr.addLanguage("en", {
 
 // can be used for different locales too:
 
-tr.addLanguage("en-US", {
+tr.addTranslations("en-US", {
   fries: "fries",
   color: "color",
 });
 
-tr.addLanguage("en-GB", {
+tr.addTranslations("en-GB", {
   fries: "chips",
   color: "colour",
 });
@@ -2419,15 +2450,15 @@ const translation_de = {
   foo: "Foo",
 };
 
-tr.addLanguage("de-DE", translation_de);
+tr.addTranslations("de-DE", translation_de);
 
-tr.addLanguage("de-CH", {
+tr.addTranslations("de-CH", {
   // overwrite the "greeting" but keep other keys as they are:
   ...translation_de,
   greeting: "Grüezi!",
 });
 
-tr.addLanguage("de-AT", {
+tr.addTranslations("de-AT", {
   // overwrite "greeting" again but keep other keys as they are:
   ...translation_de,
   greeting: "Grüß Gott!",
@@ -2435,7 +2466,7 @@ tr.addLanguage("de-AT", {
 
 // example for custom pluralization using a predefined suffix:
 
-tr.addLanguage("en", {
+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",
@@ -2487,57 +2518,190 @@ console.log(trpl("cart_items_added", someVal, someVal)); // "Added NaN items to
 
 <br>
 
-### tr.setLanguage()
+### tr.getTranslations()
 Usage:  
 ```ts
-tr.setLanguage(language: string): void
+tr.getTranslations(language?: string): Record<string, string | object> | 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.  
+  
+<details><summary><b>Example - click to view</b></summary>
+
+```ts
+import { tr } from "@sv443-network/userutils";
+
+tr.addTranslations("en", {
+  welcome: "Welcome",
+});
+
+console.log(tr.getTranslations());     // undefined
+tr.setLanguage("en");
+console.log(tr.getTranslations());     // { "welcome": "Welcome" }
+
+console.log(tr.getTranslations("en")); // { "welcome": "Welcome" }
+
+console.log(tr.getTranslations("de")); // undefined
 ```
+</details>
 
-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.addLanguage()` before calling `tr()`  
+<br>
+
+### tr.deleteTranslations()
+Usage:  
+```ts
+tr.deleteTranslations(language?: string): void
+```
   
-For an example, please see [`tr()`](#tr)
+Deletes the translations of the specified language.  
+If no language is given, tries to use the currently active language set by [`tr.setLanguage()`](#trsetlanguage)  
+  
+<details><summary><b>Example - click to view</b></summary>
+
+```ts
+import { tr } from "@sv443-network/userutils";
+
+tr.addTranslations("en", {
+  welcome: "Welcome",
+});
+
+tr.addTranslations("de", {
+  welcome: "Willkommen",
+});
+
+console.log(tr.getTranslations("en")); // { "welcome": "Welcome" }
+
+tr.setLanguage("en");
+
+// no language code needed anymore because of tr.setLanguage("en"):
+tr.deleteTranslations();
+
+console.log(tr.getTranslations()); // undefined
+```
+</details>
 
 <br>
 
-### tr.getLanguage()
+### tr.hasKey()
 Usage:  
 ```ts
-tr.getLanguage(): string | undefined
+tr.hasKey(key: string, language?: string): 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)  
+  
+<details><summary><b>Example - click to view</b></summary>
 
-Returns the currently active language set by [`tr.setLanguage()`](#trsetlanguage)  
-If no language has been set yet, it will return undefined.
+```ts
+import { tr } from "@sv443-network/userutils";
+
+tr.addTranslations("en", {
+  welcome: "Welcome",
+});
+
+tr.setLanguage("en");
+
+console.log(tr.hasKey("welcome"));       // true
+console.log(tr.hasKey("foo"));           // false
+console.log(tr.hasKey("welcome", "de")); // false
+```
+</details>
 
 <br>
 
-### tr.getTranslations()
+### tr.addTransform()
 Usage:  
 ```ts
-tr.getTranslations(language?: string): Record<string, string | object> | undefined
-```  
+tr.addTransform(pattern: RegExp | string, fn: (matches: RegExpMatchArray, language: string) => Stringifiable): void
+```
   
-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.  
+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.  
   
 <details><summary><b>Example - click to view</b></summary>
 
 ```ts
 import { tr } from "@sv443-network/userutils";
 
-tr.addLanguage("en", {
-  welcome: "Welcome",
+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>",
 });
 
-console.log(tr.getTranslations());     // undefined
 tr.setLanguage("en");
-console.log(tr.getTranslations());     // { "welcome": "Welcome" }
 
-console.log(tr.getTranslations("en")); // { "welcome": "Welcome" }
+// 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";
+  }
+});
 
-console.log(tr.getTranslations("de")); // undefined
+// 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>`;
+});
+
+// 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"
+
+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>`
+```
+</details>
+
+<br>
+
+### tr.deleteTransform()
+Usage:  
+```ts
+tr.deleteTransform(patternOrFn: RegExp | string | ((matches: RegExpMatchArray, language: string) => Stringifiable)): void
+```
+  
+Deletes the transformation function that matches the specified pattern or function.  
+If the function is not found, nothing will happen.  
+  
+<details><summary><b>Example - click to view</b></summary>
+
+```ts
+import { tr } 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";
+  }
+};
+
+tr.addTransform(/<\$([A-Z_]+)>/gm, globalValTransform);
+
+console.log(tr("welcome")); // "Welcome, John"
+
+tr.deleteTransform(globalValTransform);
+
+console.log(tr("welcome")); // "Welcome, <$USERNAME>"
 ```
 </details>
 

+ 1 - 0
lib/misc.ts

@@ -31,6 +31,7 @@ export function pauseFor(time: number): Promise<void> {
   });
 }
 
+// TODO:FIXME: https://github.com/Sv443-Network/UserUtils/issues/46
 /**
  * Calls the passed {@linkcode func} after the specified {@linkcode timeout} in ms (defaults to 300).  
  * Any subsequent calls to this function will reset the timer and discard all previous calls.

+ 170 - 74
lib/translation.ts

@@ -2,41 +2,63 @@ import { insertValues } from "./misc.js";
 import type { Stringifiable } from "./types.js";
 
 /**
- * Translation object to pass to {@linkcode tr.addLanguage()}  
+ * 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;
+ [key: string]: string | TrObject;
 }
 
-/** All translations and some metadata */
+/** Function that transforms a matched translation string into something else */
+export type TransformFn = (matches: RegExpMatchArray, language: string) => Stringifiable;
+
+/** All translations loaded into memory */
 const trans: {
-  [language: string]: TrObject;
+ [language: string]: TrObject;
 } = {};
+
+/** All registered value transformers */
+const valTransforms: Array<{
+ regex: RegExp;
+ fn: (matches: RegExpMatchArray, language: string) => Stringifiable;
+}> = [];
+
 /** Currently set language */
 let curLang = "";
 
 /** Common function to resolve the translation text in a specific language. */
 function translate(language: string, key: string, ...args: Stringifiable[]): string {
-  const trObj = trans[language]?.data;
-  if(typeof language !== "string" || typeof trObj !== "object" || trObj === null)
+  if(typeof language !== "string")
+    language = curLang ?? "";
+
+  const trObj = trans[language];
+
+  if(typeof language !== "string" || language.length === 0 || typeof trObj !== "object" || trObj === null)
     return key;
 
+  const transform = (value: string): string => {
+    const tf = valTransforms.find((t) => t.regex.test(value));
+
+    return tf
+      ? value.replace(tf.regex, (...matches) => String(tf.fn(matches, language)))
+      : value;
+  };
+
   // try to resolve via traversal (e.g. `trObj["key"]["parts"]`)
   const keyParts = key.split(".");
   let value: string | TrObject | undefined = trObj;
@@ -46,87 +68,161 @@ function translate(language: string, key: string, ...args: Stringifiable[]): str
     value = value?.[part];
   }
   if(typeof value === "string")
-    return insertValues(value, args);
+    return transform(insertValues(value, args));
 
   // try falling back to `trObj["key.parts"]`
   value = trObj?.[key];
   if(typeof value === "string")
-    return insertValues(value, args);
+    return transform(insertValues(value, args));
 
   // default to translation key
   return key;
 }
 
 /**
- * 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.addLanguage()} 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
- */
+* 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.addLanguage()} before using this function, otherwise it will always return the key itself.
- * @param language Language 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
- */
+* 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;
 
 /**
- * Registers a new language with its translations.  
- * 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 to register
- * @param translations Translations for the specified language
- * @example ```ts
- * tr.addLanguage("en", {
- *   hello: "Hello, %1!",
- *   foo: {
- *     bar: "Foo bar",
- *   },
- * });
- * ```
- */
-tr.addLanguage = (language: string, translations: TrObject): void => {
-  trans[language] = 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 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 => {
+  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.addLanguage()}, the translation functions will always return the key itself.  
- * @param language Language code to set as active
- */
+* 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 active language set by {@linkcode tr.setLanguage()}  
- * If no language is set, this function will return `undefined`.  
- * @returns Active language code
- */
-tr.getLanguage = (): string => {
-  return curLang;
+* 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;
+
+/**
+* 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];
+
+/**
+* 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];
+};
+
+/**
+* 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;
 };
 
 /**
- * Returns the translations for the specified language or currently active one.  
- * If the language is not registered with {@linkcode tr.addLanguage()}, this function will return `undefined`.  
- * @param language Language code to get translations for - defaults to the currently active language (set by {@linkcode tr.setLanguage()})
- * @returns Translations for the specified language
- */
-tr.getTranslations = (language?: string): TrObject | undefined => {
-  return trans[language ?? curLang];
+* 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: 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;
+*   }
+* });
+* 
+* // 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>";
+* });
+* 
+* tr.setLanguage("en");
+* 
+* tr("greeting.with_username"); // "Hello, JohnDoe45"
+* tr("greeting.headline"); // "<b>Hello, JohnDoe45</b>\nYou have 5 unread 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,
+  });
+};
+
+/**
+* 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));
+
+  if(idx !== -1)
+    valTransforms.splice(idx, 1);
 };
 
 export { tr };