translation.ts 16 KB

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