src/Translator.js
import baseLogger from './logger.js';
import EventEmitter from './EventEmitter.js';
import postProcessor from './postProcessor.js';
import { copy as utilsCopy, looksLikeObjectPath, isString } from './utils.js';
const checkedLoadedFor = {};
const shouldHandleAsObject = (res) =>
!isString(res) && typeof res !== 'boolean' && typeof res !== 'number';
class Translator extends EventEmitter {
constructor(services, options = {}) {
super();
utilsCopy(
[
'resourceStore',
'languageUtils',
'pluralResolver',
'interpolator',
'backendConnector',
'i18nFormat',
'utils',
],
services,
this,
);
this.options = options;
if (this.options.keySeparator === undefined) {
this.options.keySeparator = '.';
}
this.logger = baseLogger.create('translator');
}
changeLanguage(lng) {
if (lng) this.language = lng;
}
exists(key, o = { interpolation: {} }) {
const opt = { ...o };
if (key == null) return false;
const resolved = this.resolve(key, opt);
return resolved?.res !== undefined;
}
extractFromKey(key, opt) {
let nsSeparator = opt.nsSeparator !== undefined ? opt.nsSeparator : this.options.nsSeparator;
if (nsSeparator === undefined) nsSeparator = ':';
const keySeparator =
opt.keySeparator !== undefined ? opt.keySeparator : this.options.keySeparator;
let namespaces = opt.ns || this.options.defaultNS || [];
const wouldCheckForNsInKey = nsSeparator && key.indexOf(nsSeparator) > -1;
const seemsNaturalLanguage =
!this.options.userDefinedKeySeparator &&
!opt.keySeparator &&
!this.options.userDefinedNsSeparator &&
!opt.nsSeparator &&
!looksLikeObjectPath(key, nsSeparator, keySeparator);
if (wouldCheckForNsInKey && !seemsNaturalLanguage) {
const m = key.match(this.interpolator.nestingRegexp);
if (m && m.length > 0) {
return {
key,
namespaces: isString(namespaces) ? [namespaces] : namespaces,
};
}
const parts = key.split(nsSeparator);
if (
nsSeparator !== keySeparator ||
(nsSeparator === keySeparator && this.options.ns.indexOf(parts[0]) > -1)
)
namespaces = parts.shift();
key = parts.join(keySeparator);
}
return {
key,
namespaces: isString(namespaces) ? [namespaces] : namespaces,
};
}
translate(keys, o, lastKey) {
let opt = typeof o === 'object' ? { ...o } : o;
if (typeof opt !== 'object' && this.options.overloadTranslationOptionHandler) {
/* eslint prefer-rest-params: 0 */
opt = this.options.overloadTranslationOptionHandler(arguments);
}
if (typeof options === 'object') opt = { ...opt };
if (!opt) opt = {};
// non valid keys handling
if (keys == null /* || keys === '' */) return '';
if (!Array.isArray(keys)) keys = [String(keys)];
const returnDetails =
opt.returnDetails !== undefined ? opt.returnDetails : this.options.returnDetails;
// separators
const keySeparator =
opt.keySeparator !== undefined ? opt.keySeparator : this.options.keySeparator;
// get namespace(s)
const { key, namespaces } = this.extractFromKey(keys[keys.length - 1], opt);
const namespace = namespaces[namespaces.length - 1];
// return key on CIMode
const lng = opt.lng || this.language;
const appendNamespaceToCIMode =
opt.appendNamespaceToCIMode || this.options.appendNamespaceToCIMode;
if (lng?.toLowerCase() === 'cimode') {
if (appendNamespaceToCIMode) {
const nsSeparator = opt.nsSeparator || this.options.nsSeparator;
if (returnDetails) {
return {
res: `${namespace}${nsSeparator}${key}`,
usedKey: key,
exactUsedKey: key,
usedLng: lng,
usedNS: namespace,
usedParams: this.getUsedParamsDetails(opt),
};
}
return `${namespace}${nsSeparator}${key}`;
}
if (returnDetails) {
return {
res: key,
usedKey: key,
exactUsedKey: key,
usedLng: lng,
usedNS: namespace,
usedParams: this.getUsedParamsDetails(opt),
};
}
return key;
}
// resolve from store
const resolved = this.resolve(keys, opt);
let res = resolved?.res;
const resUsedKey = resolved?.usedKey || key;
const resExactUsedKey = resolved?.exactUsedKey || key;
const noObject = ['[object Number]', '[object Function]', '[object RegExp]'];
const joinArrays = opt.joinArrays !== undefined ? opt.joinArrays : this.options.joinArrays;
// object
const handleAsObjectInI18nFormat = !this.i18nFormat || this.i18nFormat.handleAsObject;
const needsPluralHandling = opt.count !== undefined && !isString(opt.count);
const hasDefaultValue = Translator.hasDefaultValue(opt);
const defaultValueSuffix = needsPluralHandling
? this.pluralResolver.getSuffix(lng, opt.count, opt)
: '';
const defaultValueSuffixOrdinalFallback =
opt.ordinal && needsPluralHandling
? this.pluralResolver.getSuffix(lng, opt.count, { ordinal: false })
: '';
const needsZeroSuffixLookup = needsPluralHandling && !opt.ordinal && opt.count === 0;
const defaultValue =
(needsZeroSuffixLookup && opt[`defaultValue${this.options.pluralSeparator}zero`]) ||
opt[`defaultValue${defaultValueSuffix}`] ||
opt[`defaultValue${defaultValueSuffixOrdinalFallback}`] ||
opt.defaultValue;
let resForObjHndl = res;
if (handleAsObjectInI18nFormat && !res && hasDefaultValue) {
resForObjHndl = defaultValue;
}
const handleAsObject = shouldHandleAsObject(resForObjHndl);
const resType = Object.prototype.toString.apply(resForObjHndl);
if (
handleAsObjectInI18nFormat &&
resForObjHndl &&
handleAsObject &&
noObject.indexOf(resType) < 0 &&
!(isString(joinArrays) && Array.isArray(resForObjHndl))
) {
if (!opt.returnObjects && !this.options.returnObjects) {
if (!this.options.returnedObjectHandler) {
this.logger.warn('accessing an object - but returnObjects options is not enabled!');
}
const r = this.options.returnedObjectHandler
? this.options.returnedObjectHandler(resUsedKey, resForObjHndl, {
...opt,
ns: namespaces,
})
: `key '${key} (${this.language})' returned an object instead of string.`;
if (returnDetails) {
resolved.res = r;
resolved.usedParams = this.getUsedParamsDetails(opt);
return resolved;
}
return r;
}
// if we got a separator we loop over children - else we just return object as is
// as having it set to false means no hierarchy so no lookup for nested values
if (keySeparator) {
const resTypeIsArray = Array.isArray(resForObjHndl);
const copy = resTypeIsArray ? [] : {}; // apply child translation on a copy
/* eslint no-restricted-syntax: 0 */
const newKeyToUse = resTypeIsArray ? resExactUsedKey : resUsedKey;
for (const m in resForObjHndl) {
if (Object.prototype.hasOwnProperty.call(resForObjHndl, m)) {
const deepKey = `${newKeyToUse}${keySeparator}${m}`;
if (hasDefaultValue && !res) {
copy[m] = this.translate(deepKey, {
...opt,
defaultValue: shouldHandleAsObject(defaultValue) ? defaultValue[m] : undefined,
...{ joinArrays: false, ns: namespaces },
});
} else {
copy[m] = this.translate(deepKey, {
...opt,
...{ joinArrays: false, ns: namespaces },
});
}
if (copy[m] === deepKey) copy[m] = resForObjHndl[m]; // if nothing found use original value as fallback
}
}
res = copy;
}
} else if (handleAsObjectInI18nFormat && isString(joinArrays) && Array.isArray(res)) {
// array special treatment
res = res.join(joinArrays);
if (res) res = this.extendTranslation(res, keys, opt, lastKey);
} else {
// string, empty or null
let usedDefault = false;
let usedKey = false;
// fallback value
if (!this.isValidLookup(res) && hasDefaultValue) {
usedDefault = true;
res = defaultValue;
}
if (!this.isValidLookup(res)) {
usedKey = true;
res = key;
}
const missingKeyNoValueFallbackToKey =
opt.missingKeyNoValueFallbackToKey || this.options.missingKeyNoValueFallbackToKey;
const resForMissing = missingKeyNoValueFallbackToKey && usedKey ? undefined : res;
// save missing
const updateMissing = hasDefaultValue && defaultValue !== res && this.options.updateMissing;
if (usedKey || usedDefault || updateMissing) {
this.logger.log(
updateMissing ? 'updateKey' : 'missingKey',
lng,
namespace,
key,
updateMissing ? defaultValue : res,
);
if (keySeparator) {
const fk = this.resolve(key, { ...opt, keySeparator: false });
if (fk && fk.res)
this.logger.warn(
'Seems the loaded translations were in flat JSON format instead of nested. Either set keySeparator: false on init or make sure your translations are published in nested format.',
);
}
let lngs = [];
const fallbackLngs = this.languageUtils.getFallbackCodes(
this.options.fallbackLng,
opt.lng || this.language,
);
if (this.options.saveMissingTo === 'fallback' && fallbackLngs && fallbackLngs[0]) {
for (let i = 0; i < fallbackLngs.length; i++) {
lngs.push(fallbackLngs[i]);
}
} else if (this.options.saveMissingTo === 'all') {
lngs = this.languageUtils.toResolveHierarchy(opt.lng || this.language);
} else {
lngs.push(opt.lng || this.language);
}
const send = (l, k, specificDefaultValue) => {
const defaultForMissing =
hasDefaultValue && specificDefaultValue !== res ? specificDefaultValue : resForMissing;
if (this.options.missingKeyHandler) {
this.options.missingKeyHandler(l, namespace, k, defaultForMissing, updateMissing, opt);
} else if (this.backendConnector?.saveMissing) {
this.backendConnector.saveMissing(
l,
namespace,
k,
defaultForMissing,
updateMissing,
opt,
);
}
this.emit('missingKey', l, namespace, k, res);
};
if (this.options.saveMissing) {
if (this.options.saveMissingPlurals && needsPluralHandling) {
lngs.forEach((language) => {
const suffixes = this.pluralResolver.getSuffixes(language, opt);
if (
needsZeroSuffixLookup &&
opt[`defaultValue${this.options.pluralSeparator}zero`] &&
suffixes.indexOf(`${this.options.pluralSeparator}zero`) < 0
) {
suffixes.push(`${this.options.pluralSeparator}zero`);
}
suffixes.forEach((suffix) => {
send([language], key + suffix, opt[`defaultValue${suffix}`] || defaultValue);
});
});
} else {
send(lngs, key, defaultValue);
}
}
}
// extend
res = this.extendTranslation(res, keys, opt, resolved, lastKey);
// append namespace if still key
if (usedKey && res === key && this.options.appendNamespaceToMissingKey)
res = `${namespace}:${key}`;
// parseMissingKeyHandler
if ((usedKey || usedDefault) && this.options.parseMissingKeyHandler) {
res = this.options.parseMissingKeyHandler(
this.options.appendNamespaceToMissingKey ? `${namespace}:${key}` : key,
usedDefault ? res : undefined,
opt,
);
}
}
// return
if (returnDetails) {
resolved.res = res;
resolved.usedParams = this.getUsedParamsDetails(opt);
return resolved;
}
return res;
}
extendTranslation(res, key, opt, resolved, lastKey) {
if (this.i18nFormat?.parse) {
res = this.i18nFormat.parse(
res,
{ ...this.options.interpolation.defaultVariables, ...opt },
opt.lng || this.language || resolved.usedLng,
resolved.usedNS,
resolved.usedKey,
{ resolved },
);
} else if (!opt.skipInterpolation) {
// i18next.parsing
if (opt.interpolation)
this.interpolator.init({
...opt,
...{ interpolation: { ...this.options.interpolation, ...opt.interpolation } },
});
const skipOnVariables =
isString(res) &&
(opt?.interpolation?.skipOnVariables !== undefined
? opt.interpolation.skipOnVariables
: this.options.interpolation.skipOnVariables);
let nestBef;
if (skipOnVariables) {
const nb = res.match(this.interpolator.nestingRegexp);
// has nesting aftbeforeer interpolation
nestBef = nb && nb.length;
}
// interpolate
let data = opt.replace && !isString(opt.replace) ? opt.replace : opt;
if (this.options.interpolation.defaultVariables)
data = { ...this.options.interpolation.defaultVariables, ...data };
res = this.interpolator.interpolate(
res,
data,
opt.lng || this.language || resolved.usedLng,
opt,
);
// nesting
if (skipOnVariables) {
const na = res.match(this.interpolator.nestingRegexp);
// has nesting after interpolation
const nestAft = na && na.length;
if (nestBef < nestAft) opt.nest = false;
}
if (!opt.lng && resolved && resolved.res) opt.lng = this.language || resolved.usedLng;
if (opt.nest !== false)
res = this.interpolator.nest(
res,
(...args) => {
if (lastKey?.[0] === args[0] && !opt.context) {
this.logger.warn(
`It seems you are nesting recursively key: ${args[0]} in key: ${key[0]}`,
);
return null;
}
return this.translate(...args, key);
},
opt,
);
if (opt.interpolation) this.interpolator.reset();
}
// post process
const postProcess = opt.postProcess || this.options.postProcess;
const postProcessorNames = isString(postProcess) ? [postProcess] : postProcess;
if (res != null && postProcessorNames?.length && opt.applyPostProcessor !== false) {
res = postProcessor.handle(
postProcessorNames,
res,
key,
this.options && this.options.postProcessPassResolved
? {
i18nResolved: { ...resolved, usedParams: this.getUsedParamsDetails(opt) },
...opt,
}
: opt,
this,
);
}
return res;
}
resolve(keys, opt = {}) {
let found;
let usedKey; // plain key
let exactUsedKey; // key with context / plural
let usedLng;
let usedNS;
if (isString(keys)) keys = [keys];
// forEach possible key
keys.forEach((k) => {
if (this.isValidLookup(found)) return;
const extracted = this.extractFromKey(k, opt);
const key = extracted.key;
usedKey = key;
let namespaces = extracted.namespaces;
if (this.options.fallbackNS) namespaces = namespaces.concat(this.options.fallbackNS);
const needsPluralHandling = opt.count !== undefined && !isString(opt.count);
const needsZeroSuffixLookup = needsPluralHandling && !opt.ordinal && opt.count === 0;
const needsContextHandling =
opt.context !== undefined &&
(isString(opt.context) || typeof opt.context === 'number') &&
opt.context !== '';
const codes = opt.lngs
? opt.lngs
: this.languageUtils.toResolveHierarchy(opt.lng || this.language, opt.fallbackLng);
namespaces.forEach((ns) => {
if (this.isValidLookup(found)) return;
usedNS = ns;
if (
!checkedLoadedFor[`${codes[0]}-${ns}`] &&
this.utils?.hasLoadedNamespace &&
!this.utils?.hasLoadedNamespace(usedNS)
) {
checkedLoadedFor[`${codes[0]}-${ns}`] = true;
this.logger.warn(
`key "${usedKey}" for languages "${codes.join(
', ',
)}" won't get resolved as namespace "${usedNS}" was not yet loaded`,
'This means something IS WRONG in your setup. You access the t function before i18next.init / i18next.loadNamespace / i18next.changeLanguage was done. Wait for the callback or Promise to resolve before accessing it!!!',
);
}
codes.forEach((code) => {
if (this.isValidLookup(found)) return;
usedLng = code;
const finalKeys = [key];
if (this.i18nFormat?.addLookupKeys) {
this.i18nFormat.addLookupKeys(finalKeys, key, code, ns, opt);
} else {
let pluralSuffix;
if (needsPluralHandling)
pluralSuffix = this.pluralResolver.getSuffix(code, opt.count, opt);
const zeroSuffix = `${this.options.pluralSeparator}zero`;
const ordinalPrefix = `${this.options.pluralSeparator}ordinal${this.options.pluralSeparator}`;
// get key for plural if needed
if (needsPluralHandling) {
finalKeys.push(key + pluralSuffix);
if (opt.ordinal && pluralSuffix.indexOf(ordinalPrefix) === 0) {
finalKeys.push(
key + pluralSuffix.replace(ordinalPrefix, this.options.pluralSeparator),
);
}
if (needsZeroSuffixLookup) {
finalKeys.push(key + zeroSuffix);
}
}
// get key for context if needed
if (needsContextHandling) {
const contextKey = `${key}${this.options.contextSeparator}${opt.context}`;
finalKeys.push(contextKey);
// get key for context + plural if needed
if (needsPluralHandling) {
finalKeys.push(contextKey + pluralSuffix);
if (opt.ordinal && pluralSuffix.indexOf(ordinalPrefix) === 0) {
finalKeys.push(
contextKey + pluralSuffix.replace(ordinalPrefix, this.options.pluralSeparator),
);
}
if (needsZeroSuffixLookup) {
finalKeys.push(contextKey + zeroSuffix);
}
}
}
}
// iterate over finalKeys starting with most specific pluralkey (-> contextkey only) -> singularkey only
let possibleKey;
/* eslint no-cond-assign: 0 */
while ((possibleKey = finalKeys.pop())) {
if (!this.isValidLookup(found)) {
exactUsedKey = possibleKey;
found = this.getResource(code, ns, possibleKey, opt);
}
}
});
});
});
return { res: found, usedKey, exactUsedKey, usedLng, usedNS };
}
isValidLookup(res) {
return (
res !== undefined &&
!(!this.options.returnNull && res === null) &&
!(!this.options.returnEmptyString && res === '')
);
}
getResource(code, ns, key, options = {}) {
if (this.i18nFormat?.getResource) return this.i18nFormat.getResource(code, ns, key, options);
return this.resourceStore.getResource(code, ns, key, options);
}
getUsedParamsDetails(options = {}) {
// we need to remember to extend this array whenever new option properties are added
const optionsKeys = [
'defaultValue',
'ordinal',
'context',
'replace',
'lng',
'lngs',
'fallbackLng',
'ns',
'keySeparator',
'nsSeparator',
'returnObjects',
'returnDetails',
'joinArrays',
'postProcess',
'interpolation',
];
const useOptionsReplaceForData = options.replace && !isString(options.replace);
let data = useOptionsReplaceForData ? options.replace : options;
if (useOptionsReplaceForData && typeof options.count !== 'undefined') {
data.count = options.count;
}
if (this.options.interpolation.defaultVariables) {
data = { ...this.options.interpolation.defaultVariables, ...data };
}
// avoid reporting options (execpt count) as usedParams
if (!useOptionsReplaceForData) {
data = { ...data };
for (const key of optionsKeys) {
delete data[key];
}
}
return data;
}
static hasDefaultValue(options) {
const prefix = 'defaultValue';
for (const option in options) {
if (
Object.prototype.hasOwnProperty.call(options, option) &&
prefix === option.substring(0, prefix.length) &&
undefined !== options[option]
) {
return true;
}
}
return false;
}
}
export default Translator;