@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.
- package/package.json +18 -0
- package/src/I18n.ts +206 -0
- 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
|
+
}
|