translation.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468
  1. /**
  2. * @module lib/translation
  3. * This module contains a translation system that supports flat and nested JSON objects and value transformation functions - [see the documentation for more info](https://github.com/Sv443-Network/UserUtils/blob/main/docs.md#translation)
  4. */
  5. import type { Stringifiable } from "./types.js";
  6. //#region types
  7. /**
  8. * Translation object to pass to {@linkcode tr.addTranslations()}
  9. * Can be a flat object of identifier keys and the translation text as the value, or an infinitely nestable object containing the same.
  10. *
  11. * @example
  12. * // Flat object:
  13. * const tr_en: TrObject = {
  14. * hello: "Hello, %1!",
  15. * foo: "Foo",
  16. * };
  17. *
  18. * // Nested object:
  19. * const tr_de: TrObject = {
  20. * hello: "Hallo, %1!",
  21. * foo: {
  22. * bar: "Foo bar",
  23. * },
  24. * };
  25. */
  26. export interface TrObject {
  27. [key: string]: string | TrObject;
  28. }
  29. /** Properties for the transform function that transforms a matched translation string into something else */
  30. export type TransformFnProps<TTrKey extends string = string> = {
  31. /** The current language - empty string if not set yet */
  32. language: string;
  33. /** The matches as returned by `RegExp.exec()` */
  34. matches: RegExpExecArray[];
  35. /** The translation key */
  36. trKey: TTrKey;
  37. /** Translation value before any transformations */
  38. trValue: string;
  39. /** Current value, possibly in-between transformations */
  40. currentValue: string;
  41. /** Arguments passed to the translation function */
  42. trArgs: (Stringifiable | Record<string, Stringifiable>)[];
  43. };
  44. /** Function that transforms a matched translation string into another string */
  45. export type TransformFn<TTrKey extends string = string> = (props: TransformFnProps<TTrKey>) => Stringifiable;
  46. /** Transform pattern and function in tuple form */
  47. export type TransformTuple<TTrKey extends string = string> = [RegExp, TransformFn<TTrKey>];
  48. /**
  49. * Pass a recursive or flat translation object to this generic type to get all keys in the object.
  50. * @example ```ts
  51. * type Keys = TrKeys<{ a: { b: "foo" }, c: "bar" }>;
  52. * // result: type Keys = "a.b" | "c"
  53. * ```
  54. */
  55. export type TrKeys<TTrObj, P extends string = ""> = {
  56. [K in keyof TTrObj]: K extends string | number | boolean | null | undefined
  57. ? TTrObj[K] extends object
  58. ? TrKeys<TTrObj[K], `${P}${K}.`>
  59. : `${P}${K}`
  60. : never
  61. }[keyof TTrObj];
  62. //#region vars
  63. /** All translations loaded into memory */
  64. const trans: {
  65. [language: string]: TrObject;
  66. } = {};
  67. /** All registered value transformers */
  68. const valTransforms: Array<{
  69. regex: RegExp;
  70. fn: TransformFn;
  71. }> = [];
  72. /** Fallback language - if undefined, the trKey itself will be returned if the translation is not found */
  73. let fallbackLang: string | undefined;
  74. //#region tr backend
  75. /** Common function to resolve the translation text in a specific language and apply transform functions. */
  76. function translate<TTrKey extends string = string>(language: string, key: TTrKey, ...trArgs: (Stringifiable | Record<string, Stringifiable>)[]): string {
  77. if(typeof language !== "string")
  78. language = fallbackLang ?? "";
  79. const trObj = trans[language];
  80. if(typeof language !== "string" || language.length === 0 || typeof trObj !== "object" || trObj === null)
  81. return fallbackLang ? translate(fallbackLang, key, ...trArgs) : key;
  82. /** Apply all transforms that match the translation string */
  83. const transformTrVal = (trKey: TTrKey, trValue: string): string => {
  84. const tfs = valTransforms.filter(({ regex }) => new RegExp(regex).test(String(trValue)));
  85. if(tfs.length === 0)
  86. return String(trValue);
  87. let retStr = String(trValue);
  88. for(const tf of tfs) {
  89. const re = new RegExp(tf.regex);
  90. const matches: RegExpExecArray[] = [];
  91. let execRes: RegExpExecArray | null;
  92. while((execRes = re.exec(trValue)) !== null) {
  93. if(matches.some(m => m[0] === execRes?.[0]))
  94. break;
  95. matches.push(execRes);
  96. }
  97. retStr = String(tf.fn({
  98. language,
  99. trValue,
  100. currentValue: retStr,
  101. matches,
  102. trKey,
  103. trArgs,
  104. }));
  105. }
  106. return retStr;
  107. };
  108. // try to resolve via traversal (e.g. `trObj["key"]["parts"]`)
  109. const keyParts = key.split(".");
  110. let value: string | TrObject | undefined = trObj;
  111. for(const part of keyParts) {
  112. if(typeof value !== "object" || value === null) {
  113. value = undefined;
  114. break;
  115. }
  116. value = value?.[part];
  117. }
  118. if(typeof value === "string")
  119. return transformTrVal(key, value);
  120. // try falling back to `trObj["key.parts"]`
  121. value = trObj?.[key];
  122. if(typeof value === "string")
  123. return transformTrVal(key, value);
  124. // default to fallbackLang or translation key
  125. return fallbackLang
  126. ? translate(fallbackLang, key, ...trArgs)
  127. : key;
  128. }
  129. //#region tr funcs
  130. /**
  131. * Returns the translated text for the specified key in the specified language.
  132. * If the key is not found in the specified previously registered translation, the key itself is returned.
  133. *
  134. * ⚠️ Remember to register a language with {@linkcode tr.addTranslations()} before using this function, otherwise it will always return the key itself.
  135. * @param language Language code or name to use for the translation
  136. * @param key Key of the translation to return
  137. * @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
  138. */
  139. function trFor<TTrKey extends string = string>(language: string, key: TTrKey, ...args: (Stringifiable | Record<string, Stringifiable>)[]): string {
  140. const txt = translate(language, key, ...args);
  141. if(txt === key)
  142. return fallbackLang
  143. ? translate(fallbackLang, key, ...args)
  144. : key;
  145. return txt;
  146. }
  147. /**
  148. * Prepares a translation function for a specific language.
  149. * @example ```ts
  150. * tr.addTranslations("en", {
  151. * hello: "Hello, %1!",
  152. * });
  153. * const t = tr.useTr("en");
  154. * t("hello", "John"); // "Hello, John!"
  155. * ```
  156. */
  157. function useTr<TTrKey extends string = string>(language: string): (key: TTrKey, ...args: (Stringifiable | Record<string, Stringifiable>)[]) => ReturnType<typeof trFor<TTrKey>> {
  158. return (key: TTrKey, ...args: (Stringifiable | Record<string, Stringifiable>)[]) =>
  159. translate<TTrKey>(language, key, ...args);
  160. }
  161. /**
  162. * Checks if a translation exists given its {@linkcode key} in the specified {@linkcode language} or the set fallback language.
  163. * If the given language was not registered with {@linkcode tr.addTranslations()}, this function will return `false`.
  164. * @param key Key of the translation to check for
  165. * @param language Language code or name to check in - defaults to the currently active language (set by {@linkcode tr.setLanguage()})
  166. * @returns Whether the translation key exists in the specified language - always returns `false` if no language is given and no active language was set
  167. */
  168. function hasKey<TTrKey extends string = string>(language = fallbackLang ?? "", key: TTrKey): boolean {
  169. return tr.for(language, key) !== key;
  170. }
  171. //#region manage translations
  172. /**
  173. * Registers a new language and its translations - if the language already exists, it will be overwritten.
  174. * The translations are a key-value pair where the key is the translation key and the value is the translated text.
  175. * The translations can also be infinitely nested objects, resulting in a dot-separated key.
  176. * @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)
  177. * @param translations Translations for the specified language
  178. * @example ```ts
  179. * tr.addTranslations("en", {
  180. * hello: "Hello, %1!",
  181. * foo: {
  182. * bar: "Foo bar",
  183. * },
  184. * });
  185. * ```
  186. */
  187. function addTranslations(language: string, translations: TrObject): void {
  188. trans[language] = JSON.parse(JSON.stringify(translations));
  189. }
  190. /**
  191. * Returns the translation object for the specified language or currently active one.
  192. * If the language is not registered with {@linkcode tr.addTranslations()}, this function will return `undefined`.
  193. * @param language Language code or name to get translations for - defaults to the currently active language (set by {@linkcode tr.setLanguage()})
  194. * @returns Translations for the specified language
  195. */
  196. function getTranslations(language = fallbackLang ?? ""): TrObject | undefined {
  197. return trans[language];
  198. }
  199. /**
  200. * Deletes the translations for the specified language from memory.
  201. * @param language Language code or name to delete
  202. * @returns Whether the translations for the passed language were successfully deleted
  203. */
  204. const deleteTranslations = (language: string): boolean => {
  205. if(language in trans) {
  206. delete trans[language];
  207. return true;
  208. }
  209. return false;
  210. };
  211. //#region set fb lang
  212. /**
  213. * The fallback language to use when a translation key is not found in the currently active language.
  214. * Leave undefined to disable fallbacks and just return the translation key if translations are not found.
  215. */
  216. function setFallbackLanguage(fallbackLanguage?: string): void {
  217. fallbackLang = fallbackLanguage;
  218. }
  219. /** Returns the fallback language set by {@linkcode tr.setFallbackLanguage()} */
  220. function getFallbackLanguage(): string | undefined {
  221. return fallbackLang;
  222. }
  223. //#region transforms
  224. /**
  225. * Adds a transform function that gets called after resolving a translation for any language.
  226. * 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.
  227. * 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.
  228. * After all %n-formatted values have been injected, the transform functions will be called sequentially in the order they were added.
  229. * @example
  230. * ```ts
  231. * import { tr, type TrKeys } from "@sv443-network/userutils";
  232. *
  233. * const transEn = {
  234. * "headline": {
  235. * "basic": "Hello, ${USERNAME}",
  236. * "html": "Hello, ${USERNAME}<br><c=red>You have ${UNREAD_NOTIFS} unread notifications.</c>"
  237. * }
  238. * } as const;
  239. *
  240. * tr.addTranslations("en", transEn);
  241. *
  242. * // replace ${PATTERN} with predefined values
  243. * tr.addTransform(/\$\{([A-Z_]+)\}/g, ({ matches }) => {
  244. * switch(matches?.[1]) {
  245. * default:
  246. * return `[UNKNOWN: ${matches?.[1]}]`;
  247. * // these would be grabbed from elsewhere in the application, like a DataStore, global state or variable:
  248. * case "USERNAME":
  249. * return "JohnDoe45";
  250. * case "UNREAD_NOTIFS":
  251. * return 5;
  252. * }
  253. * });
  254. *
  255. * // replace <c=red>...</c> with <span style="color: red;">...</span>
  256. * tr.addTransform(/<c=([a-z]+)>(.*?)<\/c>/g, ({ matches }) => {
  257. * const color = matches?.[1];
  258. * const content = matches?.[2];
  259. *
  260. * return `<span style="color: ${color};">${content}</span>`;
  261. * });
  262. *
  263. * const t = tr.use<TrKeys<typeof transEn>>("en");
  264. *
  265. * t("headline.basic"); // "Hello, JohnDoe45"
  266. * t("headline.html"); // "Hello, JohnDoe45<br><span style="color: red;">You have 5 unread notifications.</span>"
  267. * ```
  268. * @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
  269. */
  270. function addTransform<TTrKey extends string = string>(transform: TransformTuple<TTrKey>): void {
  271. const [pattern, fn] = transform;
  272. valTransforms.push({
  273. fn: fn as TransformFn,
  274. regex: typeof pattern === "string"
  275. ? new RegExp(pattern, "gm")
  276. : pattern
  277. });
  278. }
  279. /**
  280. * Deletes the first transform function from the list of registered transform functions.
  281. * @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
  282. * @returns Returns true if the transform function was found and deleted, false if it wasn't found
  283. */
  284. function deleteTransform(patternOrFn: RegExp | string | TransformFn): boolean {
  285. const idx = valTransforms.findIndex((t) =>
  286. typeof patternOrFn === "function"
  287. ? t.fn === patternOrFn
  288. : (
  289. typeof patternOrFn === "string"
  290. ? t.regex.source === patternOrFn
  291. : t.regex === patternOrFn
  292. )
  293. );
  294. if(idx !== -1) {
  295. valTransforms.splice(idx, 1);
  296. return true;
  297. }
  298. return false;
  299. }
  300. //#region predef transforms
  301. /**
  302. * This transform will replace placeholders matching `${key}` with the value of the passed argument(s).
  303. * The arguments can be passed in keyed object form or positionally via the spread operator:
  304. * - Keyed: If the first argument is an object and `key` is found in it, the value will be used for the replacement.
  305. * - 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.
  306. *
  307. * @example ```ts
  308. * tr.addTranslations("en", {
  309. * "greeting": "Hello, ${user}!\nYou have ${notifs} notifications.",
  310. * });
  311. * tr.addTransform(tr.transforms.templateLiteral);
  312. *
  313. * const t = tr.use("en");
  314. *
  315. * // both calls return the same result:
  316. * t("greeting", { user: "John", notifs: 5 }); // "Hello, John!\nYou have 5 notifications."
  317. * t("greeting", "John", 5); // "Hello, John!\nYou have 5 notifications."
  318. *
  319. * // when a key isn't found in the object, it will be left as-is:
  320. * t("greeting", { user: "John" }); // "Hello, John!\nYou have ${notifs} notifications."
  321. * ```
  322. */
  323. const templateLiteralTransform: TransformTuple<string> = [
  324. /\$\{([a-zA-Z0-9$_-]+)\}/gm,
  325. ({ matches, trArgs, trValue }) => {
  326. const patternStart = "${",
  327. patternEnd = "}",
  328. patternRegex = /\$\{.+\}/m;
  329. let str = String(trValue);
  330. const eachKeyInTrString = (keys: string[]): boolean => keys.every((key) => trValue.includes(`${patternStart}${key}${patternEnd}`));
  331. const namedMapping = (): void => {
  332. if(!str.includes(patternStart) || typeof trArgs[0] === "undefined" || typeof trArgs[0] !== "object" || !eachKeyInTrString(Object.keys(trArgs[0] ?? {})))
  333. return;
  334. for(const match of matches) {
  335. const repl = match[1] !== undefined ? (trArgs[0] as Record<string, string>)[match[1]] : undefined;
  336. if(typeof repl !== "undefined")
  337. str = str.replace(match[0], String(repl));
  338. }
  339. };
  340. const positionalMapping = (): void => {
  341. if(!(patternRegex.test(str)) || !trArgs[0])
  342. return;
  343. let matchNum = -1;
  344. for(const match of matches) {
  345. matchNum++;
  346. if(typeof trArgs[matchNum] !== "undefined")
  347. str = str.replace(match[0], String(trArgs[matchNum]));
  348. }
  349. };
  350. /** Whether the first args parameter is an object that doesn't implement a custom `toString` method */
  351. const isArgsObject = trArgs[0] && typeof trArgs[0] === "object" && trArgs[0] !== null && String(trArgs[0]).startsWith("[object");
  352. if(isArgsObject && eachKeyInTrString(Object.keys(trArgs[0]!)))
  353. namedMapping();
  354. else
  355. positionalMapping();
  356. return str;
  357. },
  358. ] as const;
  359. /**
  360. * This transform will replace `%n` placeholders with the value of the passed arguments.
  361. * The `%n` placeholders are 1-indexed, meaning `%1` will be replaced by the first argument (index 0), `%2` by the second (index 1), and so on.
  362. * Objects will be stringified via `String()` before being inserted.
  363. *
  364. * @example ```ts
  365. * tr.addTranslations("en", {
  366. * "greeting": "Hello, %1!\nYou have %2 notifications.",
  367. * });
  368. * tr.addTransform(tr.transforms.percent);
  369. *
  370. * const t = tr.use("en");
  371. *
  372. * // arguments are inserted in the order they're passed:
  373. * t("greeting", "John", 5); // "Hello, John!\nYou have 5 notifications."
  374. *
  375. * // when a value isn't found, the placeholder will be left as-is:
  376. * t("greeting", "John"); // "Hello, John!\nYou have %2 notifications."
  377. * ```
  378. */
  379. const percentTransform: TransformTuple<string> = [
  380. /%(\d+)/gm,
  381. ({ matches, trArgs, trValue }) => {
  382. let str = String(trValue);
  383. for(const match of matches) {
  384. const repl = match[1] !== undefined
  385. ? (trArgs as Stringifiable[])?.[Number(match[1]) - 1]
  386. : undefined;
  387. if(typeof repl !== "undefined")
  388. str = str.replace(match[0], String(repl));
  389. }
  390. return str;
  391. },
  392. ] as const;
  393. //#region exports
  394. const tr = {
  395. for: <TTrKey extends string = string>(...params: Parameters<typeof trFor<TTrKey>>): ReturnType<typeof trFor<TTrKey>> =>
  396. trFor<TTrKey>(...params as Parameters<typeof trFor<TTrKey>>),
  397. use: <TTrKey extends string = string>(...params: Parameters<typeof useTr<TTrKey>>): ReturnType<typeof useTr<TTrKey>> =>
  398. useTr<TTrKey>(...params as Parameters<typeof useTr<TTrKey>>),
  399. hasKey: <TTrKey extends string = string>(language = fallbackLang ?? "", key: TTrKey): ReturnType<typeof hasKey<TTrKey>> =>
  400. hasKey<TTrKey>(language, key),
  401. addTranslations,
  402. getTranslations,
  403. deleteTranslations,
  404. setFallbackLanguage,
  405. getFallbackLanguage,
  406. addTransform,
  407. deleteTransform,
  408. transforms: {
  409. templateLiteral: templateLiteralTransform,
  410. percent: percentTransform,
  411. },
  412. };
  413. export { tr };