ember-intl
Version:
Internationalization for Ember projects
432 lines (403 loc) • 11.3 kB
JavaScript
import { cancel, next } from '@ember/runloop';
import Service from '@ember/service';
import { isHTMLSafe, htmlSafe } from '@ember/template';
import { tracked } from '@glimmer/tracking';
import { createIntlCache, createIntl } from '@formatjs/intl';
import { getOwner } from '@ember/owner';
import { g, i } from 'decorator-transforms/runtime-esm';
function formatDate(intlShape, ...[value, formatOptions]) {
return intlShape.formatDate(value, formatOptions);
}
function formatDateRange(intlShape, ...[from, to, formatOptions]) {
return intlShape.formatDateTimeRange(from, to, formatOptions);
}
function formatDisplayName(intlShape, ...[value, formatOptions]) {
return intlShape.formatDisplayName(value, formatOptions) ?? '';
}
function formatList(intlShape, ...[value, formatOptions]) {
return intlShape.formatList(value, formatOptions);
}
function formatMessage(intlShape, ...[descriptor, parameters]) {
return intlShape.formatMessage(descriptor, parameters, {
ignoreTag: true
});
}
function formatNumber(intlShape, ...[value, formatOptions]) {
return intlShape.formatNumber(value, formatOptions);
}
function formatRelativeTime(intlShape, ...[value, unit, formatOptions]) {
return intlShape.formatRelativeTime(value, unit, formatOptions);
}
function formatTime(intlShape, ...[value, formatOptions]) {
return intlShape.formatTime(value, formatOptions);
}
function convertToFormatjsFormats(formats) {
const formatjsFormats = {
dateTimeRange: formats.formatDateRange,
date: formats.formatDate,
number: formats.formatNumber,
relative: formats.formatRelativeTime,
time: formats.formatTime
};
return formatjsFormats;
}
const escaped = {
'&': '&',
'<': '<',
'>': '>',
'"': '"',
"'": ''',
'`': '`',
'=': '='
};
const needToEscape = /[&<>"'`=]/;
const badCharacters = /[&<>"'`=]/g;
// https://github.com/emberjs/ember.js/blob/v5.12.0/packages/%40ember/-internals/glimmer/lib/utils/string.ts#L103-L118
function escapeExpression(value) {
if (!needToEscape.test(value)) {
return value;
}
return value.replace(badCharacters, character => {
return escaped[character];
});
}
/**
* @private
*/
function escapeFormatMessageOptions(options) {
const escapedOptions = {};
for (const [key, value] of Object.entries(options)) {
let newValue;
if (isHTMLSafe(value)) {
/*
Cast `value`, an instance of `SafeString`, to a string
using `.toHTML()`. Since `value` is assumed to be safe,
we don't need to call `escapeExpression()`.
*/
newValue = value.toHTML();
} else if (typeof value === 'string') {
newValue = escapeExpression(value);
} else {
newValue = value;
}
// @ts-expect-error: Type not specific enough
escapedOptions[key] = newValue;
}
return escapedOptions;
}
/**
* @private
*/
function getHtmlElement(context) {
const owner = getOwner(context);
if (owner === undefined) {
return undefined;
}
const documentService = owner.lookup('service:-document');
return documentService?.documentElement;
}
/**
* @private
*/
function convertToArray(locale) {
if (Array.isArray(locale)) {
return locale;
}
return [locale];
}
/**
* @private
*/
function convertToString(locale) {
if (Array.isArray(locale)) {
return locale[0];
}
return locale;
}
/**
* @private
*/
function hasLocaleChanged(locale1, locale2) {
if (!Array.isArray(locale2)) {
return true;
}
return locale1.toString() !== locale2.toString();
}
/**
* @private
*/
function normalizeLocale(locale) {
return locale.replace(/_/g, '-').toLowerCase();
}
/**
* @private
*/
function flattenKeys(object) {
const result = {};
for (const key in object) {
if (!Object.prototype.hasOwnProperty.call(object, key)) {
continue;
}
const value = object[key];
// If `value` is not `null`
if (value && typeof value === 'object') {
const hash = flattenKeys(value);
for (const suffix in hash) {
const translation = hash[suffix];
if (typeof translation !== 'undefined') {
result[`${key}.${suffix}`] = translation;
}
}
} else {
if (typeof value !== 'undefined') {
result[key] = value;
}
}
}
return result;
}
class IntlService extends Service {
static {
g(this.prototype, "_intls", [tracked], function () {
return {};
});
}
#_intls = (i(this, "_intls"), void 0);
static {
g(this.prototype, "_locale", [tracked]);
}
#_locale = (i(this, "_locale"), void 0);
_cache = createIntlCache();
_formats = {};
_onFormatjsError = error => {
switch (error.code) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison
case 'MISSING_DATA':
{
console.warn(error.message);
break;
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison
case 'MISSING_TRANSLATION':
{
// Do nothing
break;
}
default:
{
throw error;
}
}
};
_onMissingTranslation = (key, locales) => {
const locale = locales.join(', ');
return `Missing translation "${key}" for locale "${locale}"`;
};
_timer;
get locales() {
return Object.keys(this._intls);
}
get primaryLocale() {
if (!this._locale) {
return;
}
return this._locale[0];
}
addTranslations(locale, translations) {
const messages = flattenKeys(translations);
this.updateIntl(locale, messages);
}
createIntl(locale, messages = {}) {
const resolvedLocale = convertToString(locale);
const formats = convertToFormatjsFormats(this._formats);
return createIntl({
defaultFormats: formats,
defaultLocale: resolvedLocale,
formats,
locale: resolvedLocale,
// @ts-expect-error: Type 'Record<string, unknown>' is not assignable
messages,
onError: this._onFormatjsError
}, this._cache);
}
exists(key, locale) {
const locales = locale ? convertToArray(locale) : this._locale;
return locales.some(locale => {
return this.getTranslation(key, locale) !== undefined;
});
}
formatDate(value, options) {
if (value === undefined || value === null) {
return '';
}
const intlShape = this.getIntlShape(options?.locale);
return formatDate(intlShape, value, options);
}
formatDateRange(from, to, options) {
if (from === undefined || from === null) {
return '';
}
if (to === undefined || to === null) {
return '';
}
const intlShape = this.getIntlShape(options?.locale);
return formatDateRange(intlShape, from, to, options);
}
formatDisplayName(value, options) {
if (value === undefined || value === null) {
return '';
}
const intlShape = this.getIntlShape(options?.locale);
return formatDisplayName(intlShape, value, options);
}
formatList(value, options) {
if (value === undefined || value === null) {
return '';
}
const intlShape = this.getIntlShape(options?.locale);
return formatList(intlShape, value, options);
}
formatMessage(value, options) {
if (value === undefined || value === null) {
return '';
}
const intlShape = this.getIntlShape(options?.locale);
const descriptor = typeof value === 'object' ? value : {
defaultMessage: value,
description: undefined,
id: value
};
if (options?.htmlSafe) {
const output = formatMessage(intlShape, descriptor, escapeFormatMessageOptions(options));
return htmlSafe(output);
}
return formatMessage(intlShape, descriptor, options);
}
formatNumber(value, options) {
if (value === undefined || value === null) {
return '';
}
const intlShape = this.getIntlShape(options?.locale);
return formatNumber(intlShape, value, options);
}
formatRelativeTime(value, options) {
if (value === undefined || value === null) {
return '';
}
const intlShape = this.getIntlShape(options?.locale);
return formatRelativeTime(intlShape, value, options?.unit, options);
}
formatTime(value, options) {
if (value === undefined || value === null) {
return '';
}
const intlShape = this.getIntlShape(options?.locale);
return formatTime(intlShape, value, options);
}
getIntl(locale) {
const resolvedLocale = normalizeLocale(convertToString(locale));
return this._intls[resolvedLocale];
}
getIntlShape(locale) {
if (locale) {
return this.createIntl(locale);
}
return this.getIntl(this._locale);
}
getTranslation(key, locale) {
const messages = this.getIntl(locale)?.messages;
if (!messages) {
return;
}
return messages[key];
}
setFormats(formats) {
this._formats = formats;
// Call `updateIntl` to update `formats` for each locale
this.locales.forEach(locale => {
this.updateIntl(locale, {});
});
}
setLocale(locale) {
const proposedLocale = convertToArray(locale);
if (hasLocaleChanged(proposedLocale, this._locale)) {
this._locale = proposedLocale;
// eslint-disable-next-line ember/no-runloop
cancel(this._timer);
// eslint-disable-next-line ember/no-runloop
this._timer = next(() => {
this.updateDocumentLanguage();
});
}
this.updateIntl(proposedLocale);
}
setOnFormatjsError(onFormatjsError) {
this._onFormatjsError = onFormatjsError;
// Call `updateIntl` to update `onError` for each locale
this.locales.forEach(locale => {
this.updateIntl(locale, {});
});
}
setOnMissingTranslation(onMissingTranslation) {
this._onMissingTranslation = onMissingTranslation;
}
t(key, options) {
const locales = options?.locale ? [options.locale] : this._locale;
let translation;
for (const locale of locales) {
translation = this.getTranslation(key, locale);
if (translation !== undefined) {
break;
}
}
if (translation === undefined) {
return this._onMissingTranslation(key, locales, options);
}
// Bypass @formatjs/intl
if (translation === '') {
return '';
}
return this.formatMessage({
defaultMessage: translation,
id: key
}, options);
}
updateDocumentLanguage() {
const html = getHtmlElement(this);
const {
primaryLocale
} = this;
if (!html || !primaryLocale) {
return;
}
html.setAttribute('lang', primaryLocale);
}
updateIntl(locale, messages) {
const resolvedLocale = normalizeLocale(convertToString(locale));
const intl = this._intls[resolvedLocale];
let newIntl;
if (!intl) {
newIntl = this.createIntl(resolvedLocale, messages);
} else if (messages) {
newIntl = this.createIntl(resolvedLocale, {
...(intl.messages ?? {}),
...messages
});
}
if (!newIntl) {
return;
}
this._intls = {
...this._intls,
[resolvedLocale]: newIntl
};
}
willDestroy() {
super.willDestroy();
// eslint-disable-next-line ember/no-runloop
cancel(this._timer);
}
}
// DO NOT DELETE: this is how TypeScript knows how to look up your services.
export { IntlService as default };
//# sourceMappingURL=intl.js.map