use-intl
Version:
Internationalization (i18n) for React
690 lines (660 loc) • 23.4 kB
JavaScript
import { IntlMessageFormat } from 'intl-messageformat';
import { isValidElement, cloneElement } from 'react';
import { memoize, strategies } from '@formatjs/fast-memoize';
class IntlError extends Error {
constructor(code, originalMessage) {
let message = code;
if (originalMessage) {
message += ': ' + originalMessage;
}
super(message);
this.code = code;
if (originalMessage) {
this.originalMessage = originalMessage;
}
}
}
var IntlErrorCode = /*#__PURE__*/function (IntlErrorCode) {
IntlErrorCode["MISSING_MESSAGE"] = "MISSING_MESSAGE";
IntlErrorCode["MISSING_FORMAT"] = "MISSING_FORMAT";
IntlErrorCode["ENVIRONMENT_FALLBACK"] = "ENVIRONMENT_FALLBACK";
IntlErrorCode["INSUFFICIENT_PATH"] = "INSUFFICIENT_PATH";
IntlErrorCode["INVALID_MESSAGE"] = "INVALID_MESSAGE";
IntlErrorCode["INVALID_KEY"] = "INVALID_KEY";
IntlErrorCode["FORMATTING_ERROR"] = "FORMATTING_ERROR";
return IntlErrorCode;
}(IntlErrorCode || {});
/**
* `intl-messageformat` uses separate keys for `date` and `time`, but there's
* only one native API: `Intl.DateTimeFormat`. Additionally you might want to
* include both a time and a date in a value, therefore the separation doesn't
* seem so useful. We offer a single `dateTime` namespace instead, but we have
* to convert the format before `intl-messageformat` can be used.
*/
function convertFormatsToIntlMessageFormat(globalFormats, inlineFormats, timeZone) {
const mfDateDefaults = IntlMessageFormat.formats.date;
const mfTimeDefaults = IntlMessageFormat.formats.time;
const dateTimeFormats = {
...globalFormats?.dateTime,
...inlineFormats?.dateTime
};
const allFormats = {
date: {
...mfDateDefaults,
...dateTimeFormats
},
time: {
...mfTimeDefaults,
...dateTimeFormats
},
number: {
...globalFormats?.number,
...inlineFormats?.number
}
// (list is not supported in ICU messages)
};
if (timeZone) {
// The only way to set a time zone with `intl-messageformat` is to merge it into the formats
// https://github.com/formatjs/formatjs/blob/8256c5271505cf2606e48e3c97ecdd16ede4f1b5/packages/intl/src/message.ts#L15
['date', 'time'].forEach(property => {
const formats = allFormats[property];
for (const [key, value] of Object.entries(formats)) {
formats[key] = {
timeZone,
...value
};
}
});
}
return allFormats;
}
function joinPath(...parts) {
return parts.filter(Boolean).join('.');
}
/**
* Contains defaults that are used for all entry points into the core.
* See also `InitializedIntlConfiguration`.
*/
function defaultGetMessageFallback(props) {
return joinPath(props.namespace, props.key);
}
function defaultOnError(error) {
console.error(error);
}
function createCache() {
return {
dateTime: {},
number: {},
message: {},
relativeTime: {},
pluralRules: {},
list: {},
displayNames: {}
};
}
function createMemoCache(store) {
return {
create() {
return {
get(key) {
return store[key];
},
set(key, value) {
store[key] = value;
}
};
}
};
}
function memoFn(fn, cache) {
return memoize(fn, {
cache: createMemoCache(cache),
strategy: strategies.variadic
});
}
function memoConstructor(ConstructorFn, cache) {
return memoFn((...args) => new ConstructorFn(...args), cache);
}
function createIntlFormatters(cache) {
const getDateTimeFormat = memoConstructor(Intl.DateTimeFormat, cache.dateTime);
const getNumberFormat = memoConstructor(Intl.NumberFormat, cache.number);
const getPluralRules = memoConstructor(Intl.PluralRules, cache.pluralRules);
const getRelativeTimeFormat = memoConstructor(Intl.RelativeTimeFormat, cache.relativeTime);
const getListFormat = memoConstructor(Intl.ListFormat, cache.list);
const getDisplayNames = memoConstructor(Intl.DisplayNames, cache.displayNames);
return {
getDateTimeFormat,
getNumberFormat,
getPluralRules,
getRelativeTimeFormat,
getListFormat,
getDisplayNames
};
}
// Placed here for improved tree shaking. Somehow when this is placed in
// `formatters.tsx`, then it can't be shaken off from `next-intl`.
function createMessageFormatter(cache, intlFormatters) {
const getMessageFormat = memoFn((...args) => new IntlMessageFormat(args[0], args[1], args[2], {
formatters: intlFormatters,
...args[3]
}), cache.message);
return getMessageFormat;
}
function resolvePath(locale, messages, key, namespace) {
const fullKey = joinPath(namespace, key);
if (!messages) {
throw new Error(`No messages available at \`${namespace}\`.` );
}
let message = messages;
key.split('.').forEach(part => {
const next = message[part];
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (part == null || next == null) {
throw new Error(`Could not resolve \`${fullKey}\` in messages for locale \`${locale}\`.` );
}
message = next;
});
return message;
}
function prepareTranslationValues(values) {
// Workaround for https://github.com/formatjs/formatjs/issues/1467
const transformedValues = {};
Object.keys(values).forEach(key => {
let index = 0;
const value = values[key];
let transformed;
if (typeof value === 'function') {
transformed = chunks => {
const result = value(chunks);
return /*#__PURE__*/isValidElement(result) ? /*#__PURE__*/cloneElement(result, {
key: key + index++
}) : result;
};
} else {
transformed = value;
}
transformedValues[key] = transformed;
});
return transformedValues;
}
function getMessagesOrError(locale, messages, namespace, onError = defaultOnError) {
try {
if (!messages) {
throw new Error(`No messages were configured.` );
}
const retrievedMessages = namespace ? resolvePath(locale, messages, namespace) : messages;
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (!retrievedMessages) {
throw new Error(`No messages for namespace \`${namespace}\` found.` );
}
return retrievedMessages;
} catch (error) {
const intlError = new IntlError(IntlErrorCode.MISSING_MESSAGE, error.message);
onError(intlError);
return intlError;
}
}
function getPlainMessage(candidate, values) {
{
// Keep fast path in development
if (values) return undefined;
// Despite potentially no values being available, there can still be
// placeholders in the message if the user has forgotten to provide
// values. In this case we compile the message to receive an error.
const unescapedMessage = candidate.replace(/'([{}])/gi, '$1');
const hasPlaceholders = /<|{/.test(unescapedMessage);
if (!hasPlaceholders) {
return unescapedMessage;
}
}
}
function createBaseTranslator(config) {
const messagesOrError = getMessagesOrError(config.locale, config.messages, config.namespace, config.onError);
return createBaseTranslatorImpl({
...config,
messagesOrError
});
}
function createBaseTranslatorImpl({
cache,
formats: globalFormats,
formatters,
getMessageFallback = defaultGetMessageFallback,
locale,
messagesOrError,
namespace,
onError,
timeZone
}) {
const hasMessagesError = messagesOrError instanceof IntlError;
function getFallbackFromErrorAndNotify(key, code, message) {
const error = new IntlError(code, message);
onError(error);
return getMessageFallback({
error,
key,
namespace
});
}
function translateBaseFn(/** Use a dot to indicate a level of nesting (e.g. `namespace.nestedLabel`). */
key, /** Key value pairs for values to interpolate into the message. */
values, /** Provide custom formats for numbers, dates and times. */
formats) {
if (hasMessagesError) {
// We have already warned about this during render
return getMessageFallback({
error: messagesOrError,
key,
namespace
});
}
const messages = messagesOrError;
let message;
try {
message = resolvePath(locale, messages, key, namespace);
} catch (error) {
return getFallbackFromErrorAndNotify(key, IntlErrorCode.MISSING_MESSAGE, error.message);
}
if (typeof message === 'object') {
let code, errorMessage;
if (Array.isArray(message)) {
code = IntlErrorCode.INVALID_MESSAGE;
{
errorMessage = `Message at \`${joinPath(namespace, key)}\` resolved to an array, but only strings are supported. See https://next-intl.dev/docs/usage/messages#arrays-of-messages`;
}
} else {
code = IntlErrorCode.INSUFFICIENT_PATH;
{
errorMessage = `Message at \`${joinPath(namespace, key)}\` resolved to an object, but only strings are supported. Use a \`.\` to retrieve nested messages. See https://next-intl.dev/docs/usage/messages#structuring-messages`;
}
}
return getFallbackFromErrorAndNotify(key, code, errorMessage);
}
let messageFormat;
// Hot path that avoids creating an `IntlMessageFormat` instance
const plainMessage = getPlainMessage(message, values);
if (plainMessage) return plainMessage;
// Lazy init the message formatter for better tree
// shaking in case message formatting is not used.
if (!formatters.getMessageFormat) {
formatters.getMessageFormat = createMessageFormatter(cache, formatters);
}
try {
messageFormat = formatters.getMessageFormat(message, locale, convertFormatsToIntlMessageFormat(globalFormats, formats, timeZone), {
formatters: {
...formatters,
getDateTimeFormat(locales, options) {
// Workaround for https://github.com/formatjs/formatjs/issues/4279
return formatters.getDateTimeFormat(locales, {
timeZone,
...options
});
}
}
});
} catch (error) {
const thrownError = error;
return getFallbackFromErrorAndNotify(key, IntlErrorCode.INVALID_MESSAGE, thrownError.message + ('originalMessage' in thrownError ? ` (${thrownError.originalMessage})` : '') );
}
try {
const formattedMessage = messageFormat.format(
// @ts-expect-error `intl-messageformat` expects a different format
// for rich text elements since a recent minor update. This
// needs to be evaluated in detail, possibly also in regards
// to be able to format to parts.
values ? prepareTranslationValues(values) : values);
if (formattedMessage == null) {
throw new Error(`Unable to format \`${key}\` in ${namespace ? `namespace \`${namespace}\`` : 'messages'}` );
}
// Limit the function signature to return strings or React elements
return /*#__PURE__*/isValidElement(formattedMessage) ||
// Arrays of React elements
Array.isArray(formattedMessage) || typeof formattedMessage === 'string' ? formattedMessage : String(formattedMessage);
} catch (error) {
return getFallbackFromErrorAndNotify(key, IntlErrorCode.FORMATTING_ERROR, error.message);
}
}
function translateFn(/** Use a dot to indicate a level of nesting (e.g. `namespace.nestedLabel`). */
key, /** Key value pairs for values to interpolate into the message. */
values, /** Provide custom formats for numbers, dates and times. */
formats) {
const result = translateBaseFn(key, values, formats);
if (typeof result !== 'string') {
return getFallbackFromErrorAndNotify(key, IntlErrorCode.INVALID_MESSAGE, `The message \`${key}\` in ${namespace ? `namespace \`${namespace}\`` : 'messages'} didn't resolve to a string. If you want to format rich text, use \`t.rich\` instead.` );
}
return result;
}
translateFn.rich = translateBaseFn;
// Augment `translateBaseFn` to return plain strings
translateFn.markup = (key, values, formats) => {
const result = translateBaseFn(key,
// @ts-expect-error -- `MarkupTranslationValues` is practically a sub type
// of `RichTranslationValues` but TypeScript isn't smart enough here.
values, formats);
if (typeof result !== 'string') {
const error = new IntlError(IntlErrorCode.FORMATTING_ERROR, "`t.markup` only accepts functions for formatting that receive and return strings.\n\nE.g. t.markup('markup', {b: (chunks) => `<b>${chunks}</b>`})");
onError(error);
return getMessageFallback({
error,
key,
namespace
});
}
return result;
};
translateFn.raw = key => {
if (hasMessagesError) {
// We have already warned about this during render
return getMessageFallback({
error: messagesOrError,
key,
namespace
});
}
const messages = messagesOrError;
try {
return resolvePath(locale, messages, key, namespace);
} catch (error) {
return getFallbackFromErrorAndNotify(key, IntlErrorCode.MISSING_MESSAGE, error.message);
}
};
translateFn.has = key => {
if (hasMessagesError) {
return false;
}
try {
resolvePath(locale, messagesOrError, key, namespace);
return true;
} catch {
return false;
}
};
return translateFn;
}
/**
* For the strictly typed messages to work we have to wrap the namespace into
* a mandatory prefix. See https://stackoverflow.com/a/71529575/343045
*/
function resolveNamespace(namespace, namespacePrefix) {
return namespace === namespacePrefix ? undefined : namespace.slice((namespacePrefix + '.').length);
}
const SECOND = 1;
const MINUTE = SECOND * 60;
const HOUR = MINUTE * 60;
const DAY = HOUR * 24;
const WEEK = DAY * 7;
const MONTH = DAY * (365 / 12); // Approximation
const QUARTER = MONTH * 3;
const YEAR = DAY * 365;
const UNIT_SECONDS = {
second: SECOND,
seconds: SECOND,
minute: MINUTE,
minutes: MINUTE,
hour: HOUR,
hours: HOUR,
day: DAY,
days: DAY,
week: WEEK,
weeks: WEEK,
month: MONTH,
months: MONTH,
quarter: QUARTER,
quarters: QUARTER,
year: YEAR,
years: YEAR
};
function resolveRelativeTimeUnit(seconds) {
const absValue = Math.abs(seconds);
if (absValue < MINUTE) {
return 'second';
} else if (absValue < HOUR) {
return 'minute';
} else if (absValue < DAY) {
return 'hour';
} else if (absValue < WEEK) {
return 'day';
} else if (absValue < MONTH) {
return 'week';
} else if (absValue < YEAR) {
return 'month';
}
return 'year';
}
function calculateRelativeTimeValue(seconds, unit) {
// We have to round the resulting values, as `Intl.RelativeTimeFormat`
// will include fractions like '2.1 hours ago'.
return Math.round(seconds / UNIT_SECONDS[unit]);
}
function createFormatter(props) {
const {
_cache: cache = createCache(),
_formatters: formatters = createIntlFormatters(cache),
formats,
locale,
onError = defaultOnError,
timeZone: globalTimeZone
} = props;
function applyTimeZone(options) {
if (!options?.timeZone) {
if (globalTimeZone) {
options = {
...options,
timeZone: globalTimeZone
};
} else {
onError(new IntlError(IntlErrorCode.ENVIRONMENT_FALLBACK, `The \`timeZone\` parameter wasn't provided and there is no global default configured. Consider adding a global default to avoid markup mismatches caused by environment differences. Learn more: https://next-intl.dev/docs/configuration#time-zone` ));
}
}
return options;
}
function resolveFormatOrOptions(typeFormats, formatOrOptions, overrides) {
let options;
if (typeof formatOrOptions === 'string') {
const formatName = formatOrOptions;
options = typeFormats?.[formatName];
if (!options) {
const error = new IntlError(IntlErrorCode.MISSING_FORMAT, `Format \`${formatName}\` is not available.` );
onError(error);
throw error;
}
} else {
options = formatOrOptions;
}
if (overrides) {
options = {
...options,
...overrides
};
}
return options;
}
function getFormattedValue(formatOrOptions, overrides, typeFormats, formatter, getFallback) {
let options;
try {
options = resolveFormatOrOptions(typeFormats, formatOrOptions, overrides);
} catch {
return getFallback();
}
try {
return formatter(options);
} catch (error) {
onError(new IntlError(IntlErrorCode.FORMATTING_ERROR, error.message));
return getFallback();
}
}
function dateTime(value, formatOrOptions, overrides) {
return getFormattedValue(formatOrOptions, overrides, formats?.dateTime, options => {
options = applyTimeZone(options);
return formatters.getDateTimeFormat(locale, options).format(value);
}, () => String(value));
}
function dateTimeRange(start, end, formatOrOptions, overrides) {
return getFormattedValue(formatOrOptions, overrides, formats?.dateTime, options => {
options = applyTimeZone(options);
return formatters.getDateTimeFormat(locale, options).formatRange(start, end);
}, () => [dateTime(start), dateTime(end)].join(' – '));
}
function number(value, formatOrOptions, overrides) {
return getFormattedValue(formatOrOptions, overrides, formats?.number, options => formatters.getNumberFormat(locale, options).format(value), () => String(value));
}
function getGlobalNow() {
// Only read when necessary to avoid triggering a `dynamicIO` error
// unnecessarily (`now` is only needed for `format.relativeTime`)
if (props.now) {
return props.now;
} else {
onError(new IntlError(IntlErrorCode.ENVIRONMENT_FALLBACK, `The \`now\` parameter wasn't provided to \`relativeTime\` and there is no global default configured, therefore the current time will be used as a fallback. See https://next-intl.dev/docs/usage/dates-times#relative-times-usenow` ));
return new Date();
}
}
function relativeTime(date, nowOrOptions) {
try {
let nowDate, unit;
const opts = {};
if (nowOrOptions instanceof Date || typeof nowOrOptions === 'number') {
nowDate = new Date(nowOrOptions);
} else if (nowOrOptions) {
if (nowOrOptions.now != null) {
nowDate = new Date(nowOrOptions.now);
} else {
nowDate = getGlobalNow();
}
unit = nowOrOptions.unit;
opts.style = nowOrOptions.style;
// @ts-expect-error -- Types are slightly outdated
opts.numberingSystem = nowOrOptions.numberingSystem;
}
if (!nowDate) {
nowDate = getGlobalNow();
}
const dateDate = new Date(date);
const seconds = (dateDate.getTime() - nowDate.getTime()) / 1000;
if (!unit) {
unit = resolveRelativeTimeUnit(seconds);
}
// `numeric: 'auto'` can theoretically produce output like "yesterday",
// but it only works with integers. E.g. -1 day will produce "yesterday",
// but -1.1 days will produce "-1.1 days". Rounding before formatting is
// not desired, as the given dates might cross a threshold were the
// output isn't correct anymore. Example: 2024-01-08T23:00:00.000Z and
// 2024-01-08T01:00:00.000Z would produce "yesterday", which is not the
// case. By using `always` we can ensure correct output. The only exception
// is the formatting of times <1 second as "now".
opts.numeric = unit === 'second' ? 'auto' : 'always';
const value = calculateRelativeTimeValue(seconds, unit);
return formatters.getRelativeTimeFormat(locale, opts).format(value, unit);
} catch (error) {
onError(new IntlError(IntlErrorCode.FORMATTING_ERROR, error.message));
return String(date);
}
}
function list(value, formatOrOptions, overrides) {
const serializedValue = [];
const richValues = new Map();
// `formatToParts` only accepts strings, therefore we have to temporarily
// replace React elements with a placeholder ID that can be used to retrieve
// the original value afterwards.
let index = 0;
for (const item of value) {
let serializedItem;
if (typeof item === 'object') {
serializedItem = String(index);
richValues.set(serializedItem, item);
} else {
serializedItem = String(item);
}
serializedValue.push(serializedItem);
index++;
}
return getFormattedValue(formatOrOptions, overrides, formats?.list,
// @ts-expect-error -- `richValues.size` is used to determine the return type, but TypeScript can't infer the meaning of this correctly
options => {
const result = formatters.getListFormat(locale, options).formatToParts(serializedValue).map(part => part.type === 'literal' ? part.value : richValues.get(part.value) || part.value);
if (richValues.size > 0) {
return result;
} else {
return result.join('');
}
}, () => String(value));
}
return {
dateTime,
number,
relativeTime,
list,
dateTimeRange
};
}
function validateMessagesSegment(messages, invalidKeyLabels, parentPath) {
Object.entries(messages).forEach(([key, messageOrMessages]) => {
if (key.includes('.')) {
let keyLabel = key;
if (parentPath) keyLabel += ` (at ${parentPath})`;
invalidKeyLabels.push(keyLabel);
}
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (messageOrMessages != null && typeof messageOrMessages === 'object') {
validateMessagesSegment(messageOrMessages, invalidKeyLabels, joinPath(parentPath, key));
}
});
}
function validateMessages(messages, onError) {
const invalidKeyLabels = [];
validateMessagesSegment(messages, invalidKeyLabels);
if (invalidKeyLabels.length > 0) {
onError(new IntlError(IntlErrorCode.INVALID_KEY, `Namespace keys can not contain the character "." as this is used to express nesting. Please remove it or replace it with another character.
Invalid ${invalidKeyLabels.length === 1 ? 'key' : 'keys'}: ${invalidKeyLabels.join(', ')}
If you're migrating from a flat structure, you can convert your messages as follows:
import {set} from "lodash";
const input = {
"one.one": "1.1",
"one.two": "1.2",
"two.one.one": "2.1.1"
};
const output = Object.entries(input).reduce(
(acc, [key, value]) => set(acc, key, value),
{}
);
// Output:
//
// {
// "one": {
// "one": "1.1",
// "two": "1.2"
// },
// "two": {
// "one": {
// "one": "2.1.1"
// }
// }
// }
` ));
}
}
/**
* Enhances the incoming props with defaults.
*/
function initializeConfig({
formats,
getMessageFallback,
messages,
onError,
...rest
}) {
const finalOnError = onError || defaultOnError;
const finalGetMessageFallback = getMessageFallback || defaultGetMessageFallback;
{
if (messages) {
validateMessages(messages, finalOnError);
}
}
return {
...rest,
formats: formats || undefined,
messages: messages || undefined,
onError: finalOnError,
getMessageFallback: finalGetMessageFallback
};
}
export { IntlError as I, IntlErrorCode as a, createIntlFormatters as b, createFormatter as c, createCache as d, createBaseTranslator as e, defaultGetMessageFallback as f, defaultOnError as g, initializeConfig as i, resolveNamespace as r };