@stamhoofd/backend-i18n 2.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/package.json +18 -0
  2. package/src/I18n.ts +206 -0
  3. package/src/index.ts +23 -0
package/package.json ADDED
@@ -0,0 +1,18 @@
1
+ {
2
+ "name": "@stamhoofd/backend-i18n",
3
+ "version": "2.1.1",
4
+ "main": "./dist/index.js",
5
+ "types": "./dist/index.d.ts",
6
+ "license": "UNLICENCED",
7
+ "sideEffects": false,
8
+ "files": [
9
+ "src"
10
+ ],
11
+ "scripts": {
12
+ "build": "tsc -b",
13
+ "build:full": "rm -rf ./dist && yarn build"
14
+ },
15
+ "peerDependencies": {
16
+ "@simonbackx/simple-endpoints": "*"
17
+ }
18
+ }
package/src/I18n.ts ADDED
@@ -0,0 +1,206 @@
1
+ import { DecodedRequest, Request } from '@simonbackx/simple-endpoints';
2
+ import { promises as fs } from "fs";
3
+ import { countries, languages } from "@stamhoofd/locales"
4
+ import { Country } from '@stamhoofd/structures';
5
+ import path from "path"
6
+ import {logger, StyledText} from "@simonbackx/simple-logging";
7
+
8
+ export class I18n {
9
+ static loadedLocales: Map<string, Map<string, string>> = new Map()
10
+ static defaultLanguage = "nl"
11
+ static defaultCountry = Country.Belgium
12
+
13
+ static async load() {
14
+ await logger.setContext({
15
+ prefixes: [
16
+ new StyledText('[I18n] ').addClass('i18n', 'tag')
17
+ ],
18
+ tags: ['i18n']
19
+ }, async () => {
20
+ console.log("Loading locales...")
21
+ const directory = path.dirname(require.resolve("@stamhoofd/locales"))+"/" + STAMHOOFD.translationNamespace
22
+ const files = (await fs.readdir(directory, { withFileTypes: true }))
23
+ .filter((dirent) => !dirent.isDirectory())
24
+
25
+ for (const file of files ) {
26
+ const locale = file.name.substr(0, file.name.length - 5);
27
+ console.log("Loaded:" + locale)
28
+
29
+ const messages = await import(directory+"/"+file.name)
30
+ this.loadedLocales.set(locale, this.loadRecursive(messages.default))
31
+ }
32
+ })
33
+ }
34
+
35
+ static loadRecursive(messages: any, prefix: string | null = null): Map<string, string> {
36
+ const map = new Map()
37
+ for (const key in messages) {
38
+ const element = messages[key];
39
+ if (typeof (element) != "string") {
40
+ const map2 = this.loadRecursive(element, (prefix ? prefix + "." : "")+key)
41
+ map2.forEach((val, key) => map.set(key, val));
42
+ } else {
43
+ map.set((prefix ? prefix + "." : "")+key, element)
44
+ }
45
+ }
46
+ return map;
47
+ }
48
+
49
+ static isValidLocale(locale: string) {
50
+ if (locale.length == 5 && locale.substr(2, 1) == "-") {
51
+ const l = locale.substr(0, 2).toLowerCase()
52
+ const c = locale.substr(3, 2).toUpperCase()
53
+
54
+ return languages.includes(l) && countries.includes(c)
55
+ }
56
+ return false
57
+ }
58
+
59
+ language = ""
60
+ country = ""
61
+
62
+ // Reference to messages in loadedLocales
63
+ messages: Map<string, string>
64
+
65
+ get locale() {
66
+ return this.language+"-"+this.country
67
+ }
68
+
69
+ constructor(language: string, country: string) {
70
+ this.language = language
71
+ this.country = country
72
+ this.correctLanguageCountryCombination()
73
+
74
+ const m = I18n.loadedLocales.get(this.locale)
75
+ if (!m) {
76
+ throw new Error("Locale not loaded when creating I18n for "+language+"-"+country)
77
+ }
78
+
79
+ this.messages = m
80
+ }
81
+
82
+ correctLanguageCountryCombination() {
83
+ if (I18n.isValidLocale(this.locale)) {
84
+ return;
85
+ }
86
+
87
+ // Check language is valid
88
+ if (!languages.includes(this.language)) {
89
+ this.language = I18n.defaultLanguage
90
+
91
+ if (I18n.isValidLocale(this.locale)) {
92
+ return;
93
+ }
94
+ this.country = I18n.defaultCountry
95
+ return;
96
+ }
97
+
98
+ // Loop countries until valid
99
+ this.country = I18n.defaultCountry
100
+ while (!I18n.isValidLocale(this.locale)) {
101
+ const index = countries.indexOf(this.country)
102
+ if (index == countries.length - 1) {
103
+ // Last country
104
+ this.language = I18n.defaultLanguage
105
+ this.country = I18n.defaultCountry
106
+ return;
107
+ }
108
+ this.country = countries[index + 1]
109
+ }
110
+ }
111
+
112
+ switchToLocale(options: {
113
+ language?: string,
114
+ country?: string
115
+ }) {
116
+ this.country = options.country ?? this.country
117
+ this.language = options.language ?? this.language
118
+ this.correctLanguageCountryCombination()
119
+
120
+ const m = I18n.loadedLocales.get(this.locale)
121
+ if (!m) {
122
+ throw new Error("Locale not loaded, when switching to locale "+this.language+"-"+this.country)
123
+ }
124
+
125
+ this.messages = m
126
+ }
127
+
128
+ static fromRequest(request: Request|DecodedRequest<any, any, any>): I18n {
129
+ if ((request as any)._cached_i18n) {
130
+ return (request as any)._cached_i18n
131
+ }
132
+
133
+ // Try using custom property
134
+ const localeHeader = request.headers["x-locale"]
135
+ if (localeHeader && typeof localeHeader === "string" && this.isValidLocale(localeHeader)) {
136
+ const l = localeHeader.substr(0, 2).toLowerCase()
137
+ const c = localeHeader.substr(3, 2).toUpperCase()
138
+
139
+ const i18n = new I18n(l, c);
140
+ (request as any)._cached_i18n = i18n;
141
+ return i18n;
142
+ }
143
+
144
+ // Try using accept-language defaults
145
+ const acceptLanguage = request.headers["accept-language"]
146
+ if (acceptLanguage) {
147
+ const splitted = acceptLanguage.split(",");
148
+
149
+ // Loop all countries and languages in the header, until we find a valid one
150
+ for (const item of splitted) {
151
+ const trimmed = item.trim();
152
+ const localeSplit = trimmed.split(";");
153
+ const locale = localeSplit[0];
154
+
155
+ if (locale.length == 2) {
156
+ // Language
157
+ if (languages.includes(locale)) {
158
+ // Use a default country
159
+ // Country can get overriden when matching a organization
160
+ // using .setCountry(country) method
161
+ const i18n = new I18n(locale, Country.Belgium);
162
+ (request as any)._cached_i18n = i18n;
163
+ return i18n
164
+ }
165
+ } else if (locale.length === 5 && this.isValidLocale(locale)) {
166
+ const l = locale.substr(0, 2).toLowerCase()
167
+ const c = locale.substr(3, 2).toUpperCase()
168
+
169
+ // Lang + country
170
+ const i18n = new I18n(l, c);
171
+ (request as any)._cached_i18n = i18n;
172
+ return i18n
173
+ }
174
+
175
+ }
176
+ }
177
+ const i18n = new I18n(this.defaultLanguage, this.defaultCountry);
178
+ (request as any)._cached_i18n = i18n;
179
+ return i18n;
180
+ }
181
+
182
+ t(key: string, replace?: Record<string, string>) {
183
+ return this.$t(key, replace)
184
+ }
185
+
186
+ $t(key: string, replace?: Record<string, string>) {
187
+ return this.replace(this.messages.get(key) ?? key, replace)
188
+ }
189
+
190
+ escapeRegex(string) {
191
+ return string.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&');
192
+ }
193
+
194
+ replace(text: string, replace?: Record<string, string>) {
195
+ if (!replace) {
196
+ return text
197
+ }
198
+ for (const key in replace) {
199
+ if (replace.hasOwnProperty(key)) {
200
+ const value = replace[key];
201
+ text = text.replace(new RegExp("{"+this.escapeRegex(key)+"}", "g"), value)
202
+ }
203
+ }
204
+ return text
205
+ }
206
+ }
package/src/index.ts ADDED
@@ -0,0 +1,23 @@
1
+ import { DecodedRequest } from "@simonbackx/simple-endpoints/dist/src/DecodedRequest";
2
+ import { I18n } from "./I18n";
3
+
4
+ export * from "./I18n"
5
+
6
+ // Extend decoded request here
7
+ declare module '@simonbackx/simple-endpoints/dist/src/DecodedRequest' {
8
+ // eslint-disable-next-line @typescript-eslint/interface-name-prefix
9
+ interface DecodedRequest<Params, Query, Body> {
10
+ get i18n(): I18n
11
+ $t(key: string, replace?: Record<string, string>): string
12
+ }
13
+ }
14
+
15
+ Object.defineProperty(DecodedRequest.prototype, "i18n", {
16
+ get() {
17
+ return I18n.fromRequest(this)
18
+ }
19
+ })
20
+
21
+ DecodedRequest.prototype.$t = function<Params, Query, Body>(this: DecodedRequest<Params, Query, Body>, key: string, replace?: Record<string, string>): string {
22
+ return this.i18n.$t(key, replace)
23
+ }