import { insertValues } from "./misc.js";
import type { Stringifiable } from "./types.js";
/**
* 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",
* },
* };
*/
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;
/** All translations loaded into memory */
const trans: {
[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 {
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;
for(const part of keyParts) {
if(typeof value !== "object" || value === null)
break;
value = value?.[part];
}
if(typeof value === "string")
return transform(insertValues(value, args));
// try falling back to `trObj["key.parts"]`
value = trObj?.[key];
if(typeof value === "string")
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.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
*/
tr.forLang = translate;
/**
* 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.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 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;
};
/**
* 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>
You have <$UNREAD_NOTIFS> unread notifications."
* }
* });
*
* // replace <$PATTERN>
* tr.addTransform(/<\$([A-Z_]+)>/g, (matches: RegExpMatchArray, language: string) => {
* switch(matches?.[1]) {
* default: return "";
* // these would be grabbed from elsewhere in the application:
* case "USERNAME": return "JohnDoe45";
* case "UNREAD_NOTIFS": return 5;
* }
* });
*
* // replace ... with ...
* tr.addTransform(/(.*?)<\/c>/g, (matches: RegExpMatchArray, language: string) => {
* const color = matches?.[1];
* const content = matches?.[2];
*
* return "" + content + "";
* });
*
* tr.setLanguage("en");
*
* tr("greeting.with_username"); // "Hello, JohnDoe45"
* tr("greeting.headline"); // "Hello, JohnDoe45\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 };