@ladjs/i18n
Version:
i18n wrapper and Koa middleware for Lad
366 lines (320 loc) • 12.2 kB
JavaScript
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;