translation.ts 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228
  1. import { insertValues } from "./misc.js";
  2. import type { Stringifiable } from "./types.js";
  3. /**
  4. * Translation object to pass to {@linkcode tr.addTranslations()}
  5. * Can be a flat object of identifier keys and the translation text as the value, or an infinitely nestable object containing the same.
  6. *
  7. * @example
  8. * // Flat object:
  9. * const tr_en: TrObject = {
  10. * hello: "Hello, %1!",
  11. * foo: "Foo",
  12. * };
  13. *
  14. * // Nested object:
  15. * const tr_de: TrObject = {
  16. * hello: "Hallo, %1!",
  17. * foo: {
  18. * bar: "Foo bar",
  19. * },
  20. * };
  21. */
  22. export interface TrObject {
  23. [key: string]: string | TrObject;
  24. }
  25. /** Function that transforms a matched translation string into something else */
  26. export type TransformFn = (matches: RegExpMatchArray, language: string) => Stringifiable;
  27. /** All translations loaded into memory */
  28. const trans: {
  29. [language: string]: TrObject;
  30. } = {};
  31. /** All registered value transformers */
  32. const valTransforms: Array<{
  33. regex: RegExp;
  34. fn: (matches: RegExpMatchArray, language: string) => Stringifiable;
  35. }> = [];
  36. /** Currently set language */
  37. let curLang = "";
  38. /** Common function to resolve the translation text in a specific language. */
  39. function translate(language: string, key: string, ...args: Stringifiable[]): string {
  40. if(typeof language !== "string")
  41. language = curLang ?? "";
  42. const trObj = trans[language];
  43. if(typeof language !== "string" || language.length === 0 || typeof trObj !== "object" || trObj === null)
  44. return key;
  45. const transform = (value: string): string => {
  46. const tf = valTransforms.find((t) => t.regex.test(value));
  47. return tf
  48. ? value.replace(tf.regex, (...matches) => String(tf.fn(matches, language)))
  49. : value;
  50. };
  51. // try to resolve via traversal (e.g. `trObj["key"]["parts"]`)
  52. const keyParts = key.split(".");
  53. let value: string | TrObject | undefined = trObj;
  54. for(const part of keyParts) {
  55. if(typeof value !== "object" || value === null)
  56. break;
  57. value = value?.[part];
  58. }
  59. if(typeof value === "string")
  60. return transform(insertValues(value, args));
  61. // try falling back to `trObj["key.parts"]`
  62. value = trObj?.[key];
  63. if(typeof value === "string")
  64. return transform(insertValues(value, args));
  65. // default to translation key
  66. return key;
  67. }
  68. /**
  69. * Returns the translated text for the specified key in the current language set by {@linkcode tr.setLanguage()}
  70. * Use {@linkcode tr.forLang()} to get the translation for a specific language instead of the currently set one.
  71. * If the key is not found in the currently set language, the key itself is returned.
  72. *
  73. * ⚠️ 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.
  74. * @param key Key of the translation to return
  75. * @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
  76. */
  77. const tr = (key: string, ...args: Stringifiable[]): string => translate(curLang, key, ...args);
  78. /**
  79. * Returns the translated text for the specified key in the specified language.
  80. * If the key is not found in the specified previously registered translation, the key itself is returned.
  81. *
  82. * ⚠️ Remember to register a language with {@linkcode tr.addTranslations()} before using this function, otherwise it will always return the key itself.
  83. * @param language Language code or name to use for the translation
  84. * @param key Key of the translation to return
  85. * @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
  86. */
  87. tr.forLang = translate;
  88. /**
  89. * Registers a new language and its translations - if the language already exists, it will be overwritten.
  90. * The translations are a key-value pair where the key is the translation key and the value is the translated text.
  91. *
  92. * The translations can contain placeholders in the format `%n`, where `n` is the 1-indexed argument number.
  93. * These placeholders will be replaced by the arguments passed to the translation functions.
  94. * @param language Language code or name to register
  95. * @param translations Translations for the specified language
  96. * @example ```ts
  97. * tr.addTranslations("en", {
  98. * hello: "Hello, %1!",
  99. * foo: {
  100. * bar: "Foo bar",
  101. * },
  102. * });
  103. * ```
  104. */
  105. tr.addTranslations = (language: string, translations: TrObject): void => {
  106. trans[language] = JSON.parse(JSON.stringify(translations));
  107. };
  108. /**
  109. * Sets the active language for the translation functions.
  110. * This language will be used by the {@linkcode tr()} function to return the translated text.
  111. * If the language is not registered with {@linkcode tr.addTranslations()}, the translation functions will always return the key itself.
  112. * @param language Language code or name to set as active
  113. */
  114. tr.setLanguage = (language: string): void => {
  115. curLang = language;
  116. };
  117. /**
  118. * Returns the active language set by {@linkcode tr.setLanguage()}
  119. * If no language is set, this function will return `undefined`.
  120. * @returns Active language code or name
  121. */
  122. tr.getLanguage = (): string => curLang;
  123. /**
  124. * Returns the translation object for the specified language or currently active one.
  125. * If the language is not registered with {@linkcode tr.addTranslations()}, this function will return `undefined`.
  126. * @param language Language code or name to get translations for - defaults to the currently active language (set by {@linkcode tr.setLanguage()})
  127. * @returns Translations for the specified language
  128. */
  129. tr.getTranslations = (language = curLang): TrObject | undefined => trans[language];
  130. /**
  131. * Deletes the translations for the specified language from memory.
  132. * @param language Language code or name to delete
  133. */
  134. tr.deleteTranslations = (language = curLang): void => {
  135. delete trans[language];
  136. };
  137. /**
  138. * Checks if a translation exists given its {@linkcode key} in the specified {@linkcode language} or else the currently active one.
  139. * If the language is not registered with {@linkcode tr.addTranslations()}, this function will return `false`.
  140. * @param key Key of the translation to check for
  141. * @param language Language code or name to check in - defaults to the currently active language (set by {@linkcode tr.setLanguage()})
  142. * @returns Whether the translation key exists in the specified language - always returns `false` if no language is given and no active language was set
  143. */
  144. tr.hasKey = (key: string, language = curLang): boolean => {
  145. return tr.forLang(language, key) !== key;
  146. };
  147. /**
  148. * Adds a transform function that gets called after resolving a translation for any language.
  149. * 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.
  150. * 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.
  151. * After all %n-formatted values have been injected, the transform functions will be called sequentially in the order they were added.
  152. * @example
  153. * ```ts
  154. * tr.addTranslations("en", {
  155. * "greeting": {
  156. * "with_username": "Hello, <$USERNAME>",
  157. * "headline_html": "Hello, <$USERNAME><br><c=red>You have <$UNREAD_NOTIFS> unread notifications.</c>"
  158. * }
  159. * });
  160. *
  161. * // replace <$PATTERN>
  162. * tr.addTransform(/<\$([A-Z_]+)>/g, (matches: RegExpMatchArray, language: string) => {
  163. * switch(matches?.[1]) {
  164. * default: return "<UNKNOWN_PATTERN>";
  165. * // these would be grabbed from elsewhere in the application:
  166. * case "USERNAME": return "JohnDoe45";
  167. * case "UNREAD_NOTIFS": return 5;
  168. * }
  169. * });
  170. *
  171. * // replace <c=red>...</c> with <span class="color red">...</span>
  172. * tr.addTransform(/<c=([a-z]+)>(.*?)<\/c>/g, (matches: RegExpMatchArray, language: string) => {
  173. * const color = matches?.[1];
  174. * const content = matches?.[2];
  175. *
  176. * return "<span class=\"color " + color + "\">" + content + "</span>";
  177. * });
  178. *
  179. * tr.setLanguage("en");
  180. *
  181. * tr("greeting.with_username"); // "Hello, JohnDoe45"
  182. * tr("greeting.headline"); // "<b>Hello, JohnDoe45</b>\nYou have 5 unread notifications."
  183. * ```
  184. * @param pattern Regular expression or string (passed to `new RegExp(pattern, "gm")`) that should match the entire pattern that calls the transform function
  185. */
  186. tr.addTransform = (pattern: RegExp | string, fn: TransformFn): void => {
  187. valTransforms.push({
  188. regex: typeof pattern === "string" ? new RegExp(pattern, "gm") : pattern,
  189. fn,
  190. });
  191. };
  192. /**
  193. * Deletes the first transform function from the list of registered transform functions.
  194. * @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
  195. */
  196. tr.deleteTransform = (patternOrFn: RegExp | string | TransformFn): void => {
  197. const idx = typeof patternOrFn === "function"
  198. ? valTransforms.findIndex((t) => t.fn === patternOrFn)
  199. : valTransforms.findIndex((t) => t.regex === (typeof patternOrFn === "string" ? new RegExp(patternOrFn, "gm") : patternOrFn));
  200. if(idx !== -1)
  201. valTransforms.splice(idx, 1);
  202. };
  203. export { tr };