src/Interpolator.js
import {
getPathWithDefaults,
deepFind,
escape as utilsEscape,
regexEscape,
makeString,
isString,
} from './utils.js';
import baseLogger from './logger.js';
const deepFindWithDefaults = (
data,
defaultData,
key,
keySeparator = '.',
ignoreJSONStructure = true,
) => {
let path = getPathWithDefaults(data, defaultData, key);
if (!path && ignoreJSONStructure && isString(key)) {
path = deepFind(data, key, keySeparator);
if (path === undefined) path = deepFind(defaultData, key, keySeparator);
}
return path;
};
const regexSafe = (val) => val.replace(/\$/g, '$$$$');
class Interpolator {
constructor(options = {}) {
this.logger = baseLogger.create('interpolator');
this.options = options;
this.format = options?.interpolation?.format || ((value) => value);
this.init(options);
}
/* eslint no-param-reassign: 0 */
init(options = {}) {
if (!options.interpolation) options.interpolation = { escapeValue: true };
const {
escape,
escapeValue,
useRawValueToEscape,
prefix,
prefixEscaped,
suffix,
suffixEscaped,
formatSeparator,
unescapeSuffix,
unescapePrefix,
nestingPrefix,
nestingPrefixEscaped,
nestingSuffix,
nestingSuffixEscaped,
nestingOptionsSeparator,
maxReplaces,
alwaysFormat,
} = options.interpolation;
this.escape = escape !== undefined ? escape : utilsEscape;
this.escapeValue = escapeValue !== undefined ? escapeValue : true;
this.useRawValueToEscape = useRawValueToEscape !== undefined ? useRawValueToEscape : false;
this.prefix = prefix ? regexEscape(prefix) : prefixEscaped || '{{';
this.suffix = suffix ? regexEscape(suffix) : suffixEscaped || '}}';
this.formatSeparator = formatSeparator || ',';
this.unescapePrefix = unescapeSuffix ? '' : unescapePrefix || '-';
this.unescapeSuffix = this.unescapePrefix ? '' : unescapeSuffix || '';
this.nestingPrefix = nestingPrefix
? regexEscape(nestingPrefix)
: nestingPrefixEscaped || regexEscape('$t(');
this.nestingSuffix = nestingSuffix
? regexEscape(nestingSuffix)
: nestingSuffixEscaped || regexEscape(')');
this.nestingOptionsSeparator = nestingOptionsSeparator || ',';
this.maxReplaces = maxReplaces || 1000;
this.alwaysFormat = alwaysFormat !== undefined ? alwaysFormat : false;
// the regexp
this.resetRegExp();
}
reset() {
if (this.options) this.init(this.options);
}
resetRegExp() {
const getOrResetRegExp = (existingRegExp, pattern) => {
if (existingRegExp?.source === pattern) {
existingRegExp.lastIndex = 0;
return existingRegExp;
}
return new RegExp(pattern, 'g');
};
this.regexp = getOrResetRegExp(this.regexp, `${this.prefix}(.+?)${this.suffix}`);
this.regexpUnescape = getOrResetRegExp(
this.regexpUnescape,
`${this.prefix}${this.unescapePrefix}(.+?)${this.unescapeSuffix}${this.suffix}`,
);
this.nestingRegexp = getOrResetRegExp(
this.nestingRegexp,
`${this.nestingPrefix}(.+?)${this.nestingSuffix}`,
);
}
interpolate(str, data, lng, options) {
let match;
let value;
let replaces;
const defaultData =
(this.options && this.options.interpolation && this.options.interpolation.defaultVariables) ||
{};
const handleFormat = (key) => {
if (key.indexOf(this.formatSeparator) < 0) {
const path = deepFindWithDefaults(
data,
defaultData,
key,
this.options.keySeparator,
this.options.ignoreJSONStructure,
);
return this.alwaysFormat
? this.format(path, undefined, lng, { ...options, ...data, interpolationkey: key })
: path;
}
const p = key.split(this.formatSeparator);
const k = p.shift().trim();
const f = p.join(this.formatSeparator).trim();
return this.format(
deepFindWithDefaults(
data,
defaultData,
k,
this.options.keySeparator,
this.options.ignoreJSONStructure,
),
f,
lng,
{
...options,
...data,
interpolationkey: k,
},
);
};
this.resetRegExp();
const missingInterpolationHandler =
options?.missingInterpolationHandler || this.options.missingInterpolationHandler;
const skipOnVariables =
options?.interpolation?.skipOnVariables !== undefined
? options.interpolation.skipOnVariables
: this.options.interpolation.skipOnVariables;
const todos = [
{
// unescape if has unescapePrefix/Suffix
regex: this.regexpUnescape,
safeValue: (val) => regexSafe(val),
},
{
// regular escape on demand
regex: this.regexp,
safeValue: (val) => (this.escapeValue ? regexSafe(this.escape(val)) : regexSafe(val)),
},
];
todos.forEach((todo) => {
replaces = 0;
/* eslint no-cond-assign: 0 */
while ((match = todo.regex.exec(str))) {
const matchedVar = match[1].trim();
value = handleFormat(matchedVar);
if (value === undefined) {
if (typeof missingInterpolationHandler === 'function') {
const temp = missingInterpolationHandler(str, match, options);
value = isString(temp) ? temp : '';
} else if (options && Object.prototype.hasOwnProperty.call(options, matchedVar)) {
value = ''; // undefined becomes empty string
} else if (skipOnVariables) {
value = match[0];
continue; // this makes sure it continues to detect others
} else {
this.logger.warn(`missed to pass in variable ${matchedVar} for interpolating ${str}`);
value = '';
}
} else if (!isString(value) && !this.useRawValueToEscape) {
value = makeString(value);
}
const safeValue = todo.safeValue(value);
str = str.replace(match[0], safeValue);
if (skipOnVariables) {
todo.regex.lastIndex += value.length;
todo.regex.lastIndex -= match[0].length;
} else {
todo.regex.lastIndex = 0;
}
replaces++;
if (replaces >= this.maxReplaces) {
break;
}
}
});
return str;
}
nest(str, fc, options = {}) {
let match;
let value;
let clonedOptions;
// if value is something like "myKey": "lorem $(anotherKey, { "count": {{aValueInOptions}} })"
const handleHasOptions = (key, inheritedOptions) => {
const sep = this.nestingOptionsSeparator;
if (key.indexOf(sep) < 0) return key;
const c = key.split(new RegExp(`${sep}[ ]*{`));
let optionsString = `{${c[1]}`;
key = c[0];
optionsString = this.interpolate(optionsString, clonedOptions);
const matchedSingleQuotes = optionsString.match(/'/g);
const matchedDoubleQuotes = optionsString.match(/"/g);
if (
((matchedSingleQuotes?.length ?? 0) % 2 === 0 && !matchedDoubleQuotes) ||
matchedDoubleQuotes.length % 2 !== 0
) {
optionsString = optionsString.replace(/'/g, '"');
}
try {
clonedOptions = JSON.parse(optionsString);
if (inheritedOptions) clonedOptions = { ...inheritedOptions, ...clonedOptions };
} catch (e) {
this.logger.warn(`failed parsing options string in nesting for key ${key}`, e);
return `${key}${sep}${optionsString}`;
}
// assert we do not get a endless loop on interpolating defaultValue again and again
if (clonedOptions.defaultValue && clonedOptions.defaultValue.indexOf(this.prefix) > -1)
delete clonedOptions.defaultValue;
return key;
};
// regular escape on demand
while ((match = this.nestingRegexp.exec(str))) {
let formatters = [];
clonedOptions = { ...options };
clonedOptions =
clonedOptions.replace && !isString(clonedOptions.replace)
? clonedOptions.replace
: clonedOptions;
clonedOptions.applyPostProcessor = false; // avoid post processing on nested lookup
delete clonedOptions.defaultValue; // assert we do not get a endless loop on interpolating defaultValue again and again
/**
* If there is more than one parameter (contains the format separator). E.g.:
* - t(a, b)
* - t(a, b, c)
*
* And those parameters are not dynamic values (parameters do not include curly braces). E.g.:
* - Not t(a, { "key": "{{variable}}" })
* - Not t(a, b, {"keyA": "valueA", "keyB": "valueB"})
*/
let doReduce = false;
if (match[0].indexOf(this.formatSeparator) !== -1 && !/{.*}/.test(match[1])) {
const r = match[1].split(this.formatSeparator).map((elem) => elem.trim());
match[1] = r.shift();
formatters = r;
doReduce = true;
}
value = fc(handleHasOptions.call(this, match[1].trim(), clonedOptions), clonedOptions);
// is only the nesting key (key1 = '$(key2)') return the value without stringify
if (value && match[0] === str && !isString(value)) return value;
// no string to include or empty
if (!isString(value)) value = makeString(value);
if (!value) {
this.logger.warn(`missed to resolve ${match[1]} for nesting ${str}`);
value = '';
}
if (doReduce) {
value = formatters.reduce(
// eslint-disable-next-line no-loop-func
(v, f) =>
this.format(v, f, options.lng, { ...options, interpolationkey: match[1].trim() }),
value.trim(),
);
}
// Nested keys should not be escaped by default #854
// value = this.escapeValue ? regexSafe(utils.escape(value)) : regexSafe(value);
str = str.replace(match[0], value);
this.regexp.lastIndex = 0;
}
return str;
}
}
export default Interpolator;