-
Notifications
You must be signed in to change notification settings - Fork 9
/
index.js
365 lines (320 loc) · 11.9 KB
/
index.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
const process = require('process');
const { basename, extname, resolve } = require('path');
const { debuglog } = require('util');
const { toASCII } = require('punycode/');
const Boom = require('@hapi/boom');
const { I18n } = require('i18n');
const locales = require('i18n-locales');
const multimatch = require('multimatch');
const titleize = require('titleize');
const tlds = require('tlds');
const { boolean } = require('boolean');
const { getLanguage } = require('@ladjs/country-language');
const { isEmpty, sortBy, every, isFunction } = require('lodash');
const { stringify } = require('qs');
const debug = debuglog('ladjs:i18n');
const punycodedTlds = tlds.map((tld) => toASCII(tld));
class I18N {
constructor(config = {}) {
this.config = {
phrases: {},
logger: console,
directory: resolve('locales'),
locales: ['en', 'es', 'zh'],
cookie: 'locale',
cookieOptions: {
// Disable signed cookies in NODE_ENV=test
signed: process.env.NODE_ENV !== 'test'
},
expiryMs: 31556952000, // one year in ms
indent: ' ',
defaultLocale: 'en',
syncFiles: boolean(process.env.I18N_SYNC_FILES || true),
autoReload: boolean(process.env.I18N_AUTO_RELOAD || false),
updateFiles: boolean(process.env.I18N_UPDATE_FILES || true),
api: {
__: 't',
__n: 'tn',
__l: 'tl',
__h: 'th',
__mf: 'tmf'
},
lastLocaleField: 'last_locale',
ignoredRedirectGlobs: [],
redirectIgnoresNonGetMethods: true,
stringify: {
addQueryPrefix: true,
format: 'RFC1738',
arrayFormat: 'indices'
},
redirectTLDS: true,
detectLocale: false,
...config
};
// locales must be supplied as an array of string
if (!Array.isArray(this.config.locales))
throw new Error(`Locales must be an array of strings`);
// validate locales against available ones
if (!every(this.config.locales, (l) => locales.includes(l)))
throw new Error(
`Invalid locales: ${this.config.locales
.filter((string) => !locales.includes(string))
.join(', ')}`
);
// default locale must be in locales
if (!this.config.locales.includes(this.config.defaultLocale))
throw new Error(
`Default locale of ${this.config.defaultLocale} must be included in list of locales`
);
// make sure expires is not set in cookieOptions
if (this.config.cookieOptions.expires)
throw new Error(
'Please specify expiryMs config option instead of passing a Date to cookieOptions config'
);
// inherit i18n object
Object.assign(this, new I18n());
// expose shorthand API methods
this.api = {};
for (const key of Object.keys(this.config.api)) {
this[this.config.api[key]] = this[key];
this.api[key] = this[key];
this.api[this.config.api[key]] = this[key];
}
// configure i18n
this.configure(this.config);
this.translate = this.translate.bind(this);
this.translateError = this.translateError.bind(this);
this.middleware = this.middleware.bind(this);
this.redirect = this.redirect.bind(this);
}
translate(key, locale, ...args) {
locale = locale || this.config.defaultLocale;
const { phrases } = this.config;
const phrase = phrases[key];
if (typeof phrase !== 'string')
throw new Error(`translation key missing: ${key}`);
return this.api.t({ phrase, locale }, ...args);
}
translateError(key, locale, ...args) {
const string = this.translate(key, locale, ...args);
const err = new Error(string);
err.no_translate = true;
return err;
}
async middleware(ctx, next) {
const { locales, defaultLocale, phrases, cookie } = this.config;
// expose api methods to `ctx.request` and `ctx.state`
this.init(ctx.request, ctx.state);
// expose a helper function to `ctx.state.l`
// which prefixes a link/path with the locale
ctx.state.l = (path = '') => {
return `/${ctx.state.locale}${path}`;
};
// override the existing locale detection with our own
// in order of priority:
//
// 1. check the URL, if === `/de` or starts with `/de/` then locale is `de`
// 2. use a custom function, if provided in parameters
// 3. check the cookie
// 4. check Accept-Language last
// 5. check the user's lastLocale
//
// also we need to expose `ctx.pathWithoutLocale`
// as the path without locale
let locale = locales.find((l) => {
return `/${l}` === ctx.path || ctx.path.indexOf(`/${l}/`) === 0;
});
ctx.pathWithoutLocale = locale
? ctx.path.slice(`/${locale}`.length)
: ctx.path;
if (ctx.pathWithoutLocale === '') ctx.pathWithoutLocale = '/';
if (!locale) {
locale = defaultLocale;
// if "Accepts: */*" or "Accept-Language: */*"
// then the accepted locale will be the first in
// the list of provided locales, and as such we must
// preserve the defaultLocale as the preferred language
const acceptedLocale = ctx.request.acceptsLanguages([
...new Set([defaultLocale, ...locales])
]);
if (
this.config.detectLocale &&
typeof this.config.detectLocale === 'function'
) {
locale = await this.config.detectLocale.call(this, ctx);
debug('found locale via custom function using %s', locale);
} else if (
ctx.cookies.get(cookie) &&
locales.includes(ctx.cookies.get(cookie))
) {
locale = ctx.cookies.get(cookie);
debug('found locale via cookie using %s', locale);
} else if (acceptedLocale) {
locale = acceptedLocale;
debug('found locale via Accept-Language header using %s', locale);
} else if (
this.config.lastLocaleField &&
isFunction(ctx.isAuthenticated) &&
ctx.isAuthenticated() &&
ctx.state.user[this.config.lastLocaleField]
) {
// this supports API requests using the last locale of the user
locale = ctx.state.user[this.config.lastLocaleField];
debug("using logged in user's last locale %s", locale);
} else {
debug('using default locale %s', locale);
}
}
// set the locale properly
this.setLocale([ctx.request, ctx.state], locale);
ctx.locale = ctx.request.locale;
ctx.set('Content-Language', ctx.locale);
// if the locale was not available then redirect user
if (locale !== ctx.state.locale) {
debug('locale was not available redirecting user');
ctx.status = 301;
return ctx.redirect(
`/${ctx.state.locale}${
ctx.pathWithoutLocale === '/' ? '' : ctx.pathWithoutLocale
}${
isEmpty(ctx.query) ? '' : stringify(ctx.query, this.config.stringify)
}`
);
}
// available languages for a dropdown menu to change language
ctx.state.availableLanguages = sortBy(
locales.map((locale) => {
let url = `/${locale}${
ctx.pathWithoutLocale === '/' ? '' : ctx.pathWithoutLocale
}`;
// shallow clone it so we don't affect it
const query = { ...ctx.query };
if (!isEmpty(query)) {
// if `redirect_to` was in the URL then check if i18n was in there too
// that way we don't have `?redirect_to=/zh` when we switch from `zh` to `en`
if (
typeof query.redirect_to === 'string' &&
query.redirect_to !== ''
) {
for (const l of locales) {
// if it's directly `?redirect_to=/en`
if (query.redirect_to === `/${l}`) {
query.redirect_to = `/${locale}`;
break;
}
// if it's a path starting with a locale `?redirect_to=/en/foo`
if (query.redirect_to.startsWith(`/${l}/`)) {
query.redirect_to = query.redirect_to.replace(
`/${l}/`,
`/${locale}/`
);
break;
}
}
}
url += stringify(query, this.config.stringify);
}
return {
locale,
url,
name: getLanguage(locale).name[0]
};
}),
'name'
);
// get the name of the current locale's language in native language
ctx.state.currentLanguage = titleize(
getLanguage(ctx.request.locale).nativeName[0]
);
// bind `ctx.translate` as a helper func
// so you can pass `ctx.translate('SOME_KEY_IN_CONFIG');` and it will lookup
// `phrases['SOMETHING']` to get a specific and constant message
// and then it will call `t` to translate it to the user's locale
ctx.translate = function (...args) {
if (typeof args[0] !== 'string' || typeof phrases[args[0]] !== 'string')
return ctx.throw(
Boom.badRequest('Translation for your locale failed, try again')
);
args[0] = phrases[args[0]];
return ctx.request.t(...args);
};
ctx.translateError = function (...args) {
const string = ctx.translate(...args);
const err = new Error(string);
err.no_translate = true;
return err;
};
return next();
}
async redirect(ctx, next) {
debug('attempting to redirect');
// dummy-proof in case middleware is not in correct order
// (e.g. i18n.middleware -> i18n.redirect)
if (typeof ctx.request.locale === 'undefined')
throw new Error(
'Route middleware out of order, please use i18n.middleware BEFORE i18n.redirect'
);
// do not redirect static paths
if (extname(ctx.path) !== '') {
if (!this.config.redirectTLDS) return next();
const asciiFile = toASCII(basename(ctx.path));
// do a speciality check for .js.map and .css.map
// since .map is in tlds
if (
!punycodedTlds.some((tld) => asciiFile.endsWith(`.${tld}`)) ||
asciiFile.endsWith('.js.map') ||
asciiFile.endsWith('.css.map')
)
return next();
}
// if the method is not a GET request then ignore it
if (this.config.redirectIgnoresNonGetMethods && ctx.method !== 'GET')
return next();
// check against ignored/whitelisted redirect middleware paths
const match = multimatch(ctx.path, this.config.ignoredRedirectGlobs);
if (Array.isArray(match) && match.length > 0) {
debug(`multimatch found matches for ${ctx.path}:`, match);
return next();
}
// inspired by nodejs.org language support
// <https://github.com/nodejs/nodejs.org/commit/d6cdd942a8fc0fffcf6879eca124295e95991bbc#diff-78c12f5adc1848d13b1c6f07055d996eR59>
const locale = ctx.url.split('/')[1].split('?')[0];
const hasLang = this.config.locales.includes(locale);
// if the URL did not have a valid language found
// then redirect the user to their detected locale
if (!hasLang) {
ctx.status = 301;
let redirect = `/${ctx.request.locale}${ctx.path}`;
if (redirect === `/${ctx.request.locale}/`)
redirect = `/${ctx.request.locale}`;
if (!isEmpty(ctx.query))
redirect += stringify(ctx.query, this.config.stringify);
debug('no valid locale found in URL, redirecting to %s', redirect);
return ctx.redirect(redirect);
}
debug('found valid language "%s"', locale);
// set the cookie for future requests
ctx.cookies.set(this.config.cookie, locale, {
...this.config.cookieOptions,
expires: new Date(Date.now() + this.config.expiryMs)
});
debug('set cookies for locale "%s"', locale);
// if the user is logged in and ctx.isAuthenticated() exists,
// then save it as `last_locale` (variable based off lastLocaleField)
if (
this.config.lastLocaleField &&
isFunction(ctx.isAuthenticated) &&
ctx.isAuthenticated() &&
ctx.state.user[this.config.lastLocaleField] !== locale
) {
ctx.state.user[this.config.lastLocaleField] = locale;
try {
await ctx.state.user.save();
} catch (err) {
this.config.logger.error(err);
}
}
return next();
}
}
module.exports = I18N;