feat: introduce handleMissingMessage (#175)

* feat: introduce onMissingMessageHandler

* Update src/runtime/types/index.ts

Co-authored-by: Christian Kaisermann <christian@kaisermann.me>

* Update src/runtime/configs.ts

Co-authored-by: Christian Kaisermann <christian@kaisermann.me>

* Update src/runtime/types/index.ts

Co-authored-by: Christian Kaisermann <christian@kaisermann.me>

* Update src/runtime/stores/formatters.ts

Co-authored-by: Christian Kaisermann <christian@kaisermann.me>

* Update test/runtime/stores/formatters.test.ts

Co-authored-by: Christian Kaisermann <christian@kaisermann.me>

* Update test/runtime/stores/formatters.test.ts

Co-authored-by: Christian Kaisermann <christian@kaisermann.me>

* rename to handleMissingKey and optionally use return as default value

* rename also in the defaultOptions

* test for optional default result from handleMissingKey

Co-authored-by: Christian Kaisermann <christian@kaisermann.me>
This commit is contained in:
Adriano Raiano 2022-04-05 16:38:45 +02:00 committed by Christian Kaisermann
parent 9467cfb78b
commit a8b5df0442
6 changed files with 172 additions and 62 deletions

View File

@ -26,27 +26,41 @@ Method responsible for configuring some of the library behaviours such as the gl
```ts ```ts
interface InitOptions { interface InitOptions {
// the global fallback locale /** The global fallback locale **/
fallbackLocale: string fallbackLocale: string;
// the app initial locale /** The app initial locale **/
initialLocale?: string initialLocale?: string | null;
// custom time/date/number formats /** Custom time/date/number formats **/
formats?: Formats formats?: Formats;
// loading delay interval /** Loading delay interval **/
loadingDelay?: number loadingDelay?: number;
/**
* @deprecated Use `handleMissingMessage` instead.
* */
warnOnMissingMessages?: boolean;
/**
* Optional method that is executed whenever a message is missing.
* It may return a string to use as the fallback.
*/
handleMissingMessage?: MissingKeyHandler;
/**
* Whether to treat HTML/XML tags as string literal instead of parsing them as tag token.
* When this is false we only allow simple tags without any attributes
* */
ignoreTag: boolean;
} }
``` ```
**Example**: **Example**:
```js ```js
import { init } from 'svelte-i18n' import { init } from 'svelte-i18n';
init({ init({
// fallback to en if current locale is not in the dictionary // fallback to en if current locale is not in the dictionary
fallbackLocale: 'en', fallbackLocale: 'en',
initialLocale: 'pt-br', initialLocale: 'pt-br',
}) });
``` ```
##### Custom formats ##### Custom formats
@ -55,9 +69,9 @@ It's possible to define custom format styles via the `formats` property if you w
```ts ```ts
interface Formats { interface Formats {
number: Record<string, Intl.NumberFormatOptions> number: Record<string, Intl.NumberFormatOptions>;
date: Record<string, Intl.DateTimeFormatOptions> date: Record<string, Intl.DateTimeFormatOptions>;
time: Record<string, Intl.DateTimeFormatOptions> time: Record<string, Intl.DateTimeFormatOptions>;
} }
``` ```
@ -66,7 +80,7 @@ Please refer to the [Intl.NumberFormat](https://developer.mozilla.org/en-US/docs
**Example**: **Example**:
```js ```js
import { init } from 'svelte-i18n' import { init } from 'svelte-i18n';
init({ init({
// fallback to en if current locale is not in the dictionary // fallback to en if current locale is not in the dictionary
@ -76,13 +90,11 @@ init({
EUR: { style: 'currency', currency: 'EUR' }, EUR: { style: 'currency', currency: 'EUR' },
}, },
}, },
}) });
``` ```
```html ```html
<div> <div>{$_.number(123456.789, { format: 'EUR' })}</div>
{$_.number(123456.789, { format: 'EUR' })}
</div>
<!-- 123.456,79 € --> <!-- 123.456,79 € -->
``` ```
@ -96,13 +108,13 @@ Utility method to help getting a initial locale based on a pattern of the curren
**Example**: **Example**:
```js ```js
import { init, getLocaleFromHostname } from 'svelte-i18n' import { init, getLocaleFromHostname } from 'svelte-i18n';
init({ init({
// fallback to en if current locale is not in the dictionary // fallback to en if current locale is not in the dictionary
fallbackLocale: 'en', fallbackLocale: 'en',
initialLocale: getLocaleFromHostname(/^(.*?)\./), initialLocale: getLocaleFromHostname(/^(.*?)\./),
}) });
``` ```
#### `getLocaleFromPathname` #### `getLocaleFromPathname`
@ -116,13 +128,13 @@ Utility method to help getting a initial locale based on a pattern of the curren
**Example**: **Example**:
```js ```js
import { init, getLocaleFromPathname } from 'svelte-i18n' import { init, getLocaleFromPathname } from 'svelte-i18n';
init({ init({
// fallback to en if current locale is not in the dictionary // fallback to en if current locale is not in the dictionary
fallbackLocale: 'en', fallbackLocale: 'en',
initialLocale: getLocaleFromPathname(/^\/(.*?)\//), initialLocale: getLocaleFromPathname(/^\/(.*?)\//),
}) });
``` ```
#### `getLocaleFromNavigator` #### `getLocaleFromNavigator`
@ -136,13 +148,13 @@ Utility method to help getting a initial locale based on the browser's `navigato
**Example**: **Example**:
```js ```js
import { init, getLocaleFromNavigator } from 'svelte-i18n' import { init, getLocaleFromNavigator } from 'svelte-i18n';
init({ init({
// fallback to en if current locale is not in the dictionary // fallback to en if current locale is not in the dictionary
fallbackLocale: 'en', fallbackLocale: 'en',
initialLocale: getLocaleFromNavigator(), initialLocale: getLocaleFromNavigator(),
}) });
``` ```
#### `getLocaleFromQueryString` #### `getLocaleFromQueryString`
@ -154,13 +166,13 @@ init({
Utility method to help getting a initial locale based on a query string value. Utility method to help getting a initial locale based on a query string value.
```js ```js
import { init, getLocaleFromQueryString } from 'svelte-i18n' import { init, getLocaleFromQueryString } from 'svelte-i18n';
init({ init({
// fallback to en if current locale is not in the dictionary // fallback to en if current locale is not in the dictionary
fallbackLocale: 'en', fallbackLocale: 'en',
initialLocale: getLocaleFromQueryString('lang'), initialLocale: getLocaleFromQueryString('lang'),
}) });
``` ```
#### `getLocaleFromHash` #### `getLocaleFromHash`
@ -174,13 +186,13 @@ Utility method to help getting a initial locale based on a hash `{key}={value}`
**Example**: **Example**:
```js ```js
import { init, getLocaleFromHash } from 'svelte-i18n' import { init, getLocaleFromHash } from 'svelte-i18n';
init({ init({
// fallback to en if current locale is not in the dictionary // fallback to en if current locale is not in the dictionary
fallbackLocale: 'en', fallbackLocale: 'en',
initialLocale: getLocaleFromHash('lang'), initialLocale: getLocaleFromHash('lang'),
}) });
``` ```
#### `addMessages` #### `addMessages`
@ -224,10 +236,10 @@ Registers an async message `loader` for the specified `locale`. The loader queue
**Example**: **Example**:
```js ```js
import { register } from 'svelte-i18n' import { register } from 'svelte-i18n';
register('en', () => import('./_locales/en.json')) register('en', () => import('./_locales/en.json'));
register('pt', () => import('./_locales/pt.json')) register('pt', () => import('./_locales/pt.json'));
``` ```
See [how to asynchronously load dictionaries](/svelte-i18n/blob/master/docs#22-asynchronous). See [how to asynchronously load dictionaries](/svelte-i18n/blob/master/docs#22-asynchronous).

View File

@ -1,5 +1,10 @@
import type { ConfigureOptions, ConfigureOptionsInit } from './types'; import type {
import { $locale } from './stores/locale'; ConfigureOptions,
ConfigureOptionsInit,
MissingKeyHandlerInput,
} from './types';
import { $locale, getCurrentLocale, getPossibleLocales } from './stores/locale';
import { hasLocaleQueue } from './includes/loaderQueue';
interface Formats { interface Formats {
number: Record<string, any>; number: Record<string, any>;
@ -38,11 +43,28 @@ export const defaultFormats: Formats = {
}, },
}; };
/**
* Default missing key handler used in case "warnOnMissingMessages" is set to true.
*/
function defaultMissingKeyHandler({ locale, id }: MissingKeyHandlerInput) {
// istanbul ignore next
console.warn(
`[svelte-i18n] The message "${id}" was not found in "${getPossibleLocales(
locale,
).join('", "')}".${
hasLocaleQueue(getCurrentLocale())
? `\n\nNote: there are at least one loader still registered to this locale that wasn't executed.`
: ''
}`,
);
}
export const defaultOptions: ConfigureOptions = { export const defaultOptions: ConfigureOptions = {
fallbackLocale: null as any, fallbackLocale: null as any,
loadingDelay: 200, loadingDelay: 200,
formats: defaultFormats, formats: defaultFormats,
warnOnMissingMessages: true, warnOnMissingMessages: true,
handleMissingMessage: undefined,
ignoreTag: true, ignoreTag: true,
}; };
@ -57,6 +79,18 @@ export function init(opts: ConfigureOptionsInit) {
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
const initialLocale = opts.initialLocale || opts.fallbackLocale; const initialLocale = opts.initialLocale || opts.fallbackLocale;
if (rest.warnOnMissingMessages) {
delete rest.warnOnMissingMessages;
if (rest.handleMissingMessage == null) {
rest.handleMissingMessage = defaultMissingKeyHandler;
} else {
console.warn(
'[svelte-i18n] The "warnOnMissingMessages" option is deprecated. Please use the "handleMissingMessage" option instead.',
);
}
}
Object.assign(options, rest, { initialLocale }); Object.assign(options, rest, { initialLocale });
if (formats) { if (formats) {

View File

@ -9,7 +9,6 @@ import type {
JSONGetter, JSONGetter,
} from '../types'; } from '../types';
import { lookup } from '../includes/lookup'; import { lookup } from '../includes/lookup';
import { hasLocaleQueue } from '../includes/loaderQueue';
import { import {
getMessageFormatter, getMessageFormatter,
getTimeFormatter, getTimeFormatter,
@ -18,7 +17,7 @@ import {
} from '../includes/formatters'; } from '../includes/formatters';
import { getOptions } from '../configs'; import { getOptions } from '../configs';
import { $dictionary } from './dictionary'; import { $dictionary } from './dictionary';
import { getCurrentLocale, getPossibleLocales, $locale } from './locale'; import { getCurrentLocale, $locale } from './locale';
const formatMessage: MessageFormatter = (id, options = {}) => { const formatMessage: MessageFormatter = (id, options = {}) => {
let messageObj = options as MessageObject; let messageObj = options as MessageObject;
@ -43,20 +42,10 @@ const formatMessage: MessageFormatter = (id, options = {}) => {
let message = lookup(id, locale); let message = lookup(id, locale);
if (!message) { if (!message) {
if (getOptions().warnOnMissingMessages) { message =
// istanbul ignore next getOptions().handleMissingMessage?.({ locale, id, defaultValue }) ??
console.warn( defaultValue ??
`[svelte-i18n] The message "${id}" was not found in "${getPossibleLocales( id;
locale,
).join('", "')}".${
hasLocaleQueue(getCurrentLocale())
? `\n\nNote: there are at least one loader still registered to this locale that wasn't executed.`
: ''
}`,
);
}
message = defaultValue ?? id;
} else if (typeof message !== 'string') { } else if (typeof message !== 'string') {
console.warn( console.warn(
`[svelte-i18n] Message with id "${id}" must be of type "string", found: "${typeof message}". Gettin its value through the "$format" method is deprecated; use the "json" method instead.`, `[svelte-i18n] Message with id "${id}" must be of type "string", found: "${typeof message}". Gettin its value through the "$format" method is deprecated; use the "json" method instead.`,

View File

@ -72,12 +72,40 @@ export interface MessagesLoader {
(): Promise<any>; (): Promise<any>;
} }
export type MissingKeyHandlerInput = {
locale: string;
id: string;
defaultValue: string | undefined;
};
export type MissingKeyHandlerOutput = string | void | undefined;
export type MissingKeyHandler = (
input: MissingKeyHandlerInput,
) => MissingKeyHandlerOutput;
export interface ConfigureOptions { export interface ConfigureOptions {
/** The global fallback locale * */
fallbackLocale: string; fallbackLocale: string;
/** The app initial locale * */
initialLocale?: string | null; initialLocale?: string | null;
/** Custom time/date/number formats * */
formats: Formats; formats: Formats;
/** Loading delay interval * */
loadingDelay: number; loadingDelay: number;
warnOnMissingMessages: boolean; /**
* @deprecated Use `handleMissingMessage` instead.
* */
warnOnMissingMessages?: boolean;
/**
* Optional method that is executed whenever a message is missing.
* It may return a string to use as the fallback.
*/
handleMissingMessage?: MissingKeyHandler;
/**
* Whether to treat HTML/XML tags as string literal instead of parsing them as tag token.
* When this is false we only allow simple tags without any attributes
* */
ignoreTag: boolean; ignoreTag: boolean;
} }

View File

@ -1,20 +1,18 @@
/* eslint-disable node/global-require */ /* eslint-disable node/global-require */
import { get } from 'svelte/store'; import { get } from 'svelte/store';
import { import { init, getOptions, defaultFormats } from '../../src/runtime/configs';
init,
getOptions,
defaultOptions,
defaultFormats,
} from '../../src/runtime/configs';
import { $locale } from '../../src/runtime/stores/locale'; import { $locale } from '../../src/runtime/stores/locale';
const warnSpy = jest.spyOn(global.console, 'warn').mockImplementation();
beforeEach(() => { beforeEach(() => {
init(defaultOptions as any); warnSpy.mockReset();
}); });
test('inits the fallback locale', () => { test('inits the fallback locale', () => {
expect(getOptions().fallbackLocale).toBeNull(); expect(getOptions().fallbackLocale).toBeNull();
init({ init({
fallbackLocale: 'en', fallbackLocale: 'en',
}); });
@ -45,3 +43,20 @@ test('sets the minimum delay to set the loading store value', () => {
init({ fallbackLocale: 'en', loadingDelay: 300 }); init({ fallbackLocale: 'en', loadingDelay: 300 });
expect(getOptions().loadingDelay).toBe(300); expect(getOptions().loadingDelay).toBe(300);
}); });
test('defines default missing key handler if "warnOnMissingMessages" is "true"', () => {
init({ fallbackLocale: 'en', warnOnMissingMessages: true });
expect(typeof getOptions().handleMissingMessage).toBe('function');
});
test('warns about using deprecated "warnOnMissingMessages" alongside "handleMissingMessage"', () => {
init({
fallbackLocale: 'en',
warnOnMissingMessages: true,
handleMissingMessage() {},
});
expect(warnSpy).toHaveBeenCalledWith(
'[svelte-i18n] The "warnOnMissingMessages" option is deprecated. Please use the "handleMissingMessage" option instead.',
);
});

View File

@ -7,6 +7,7 @@ import type {
TimeFormatter, TimeFormatter,
DateFormatter, DateFormatter,
NumberFormatter, NumberFormatter,
MissingKeyHandler,
} from '../../../src/runtime/types/index'; } from '../../../src/runtime/types/index';
import { import {
$format, $format,
@ -39,12 +40,10 @@ addMessages('pt', require('../../fixtures/pt.json'));
addMessages('pt-BR', require('../../fixtures/pt-BR.json')); addMessages('pt-BR', require('../../fixtures/pt-BR.json'));
addMessages('pt-PT', require('../../fixtures/pt-PT.json')); addMessages('pt-PT', require('../../fixtures/pt-PT.json'));
beforeEach(() => {
init({ fallbackLocale: 'en' });
});
describe('format message', () => { describe('format message', () => {
it('formats a message by its id and the current locale', () => { it('formats a message by its id and the current locale', () => {
init({ fallbackLocale: 'en' });
expect(formatMessage({ id: 'form.field_1_name' })).toBe('Name'); expect(formatMessage({ id: 'form.field_1_name' })).toBe('Name');
}); });
@ -55,6 +54,8 @@ describe('format message', () => {
}); });
it('formats a message with interpolated values', () => { it('formats a message with interpolated values', () => {
init({ fallbackLocale: 'en' });
expect(formatMessage({ id: 'photos', values: { n: 0 } })).toBe( expect(formatMessage({ id: 'photos', values: { n: 0 } })).toBe(
'You have no photos.', 'You have no photos.',
); );
@ -67,6 +68,8 @@ describe('format message', () => {
}); });
it('formats the default value with interpolated values', () => { it('formats the default value with interpolated values', () => {
init({ fallbackLocale: 'en' });
expect( expect(
formatMessage({ formatMessage({
id: 'non-existent', id: 'non-existent',
@ -77,6 +80,8 @@ describe('format message', () => {
}); });
it('formats the key with interpolated values', () => { it('formats the key with interpolated values', () => {
init({ fallbackLocale: 'en' });
expect( expect(
formatMessage({ formatMessage({
id: '{food}', id: '{food}',
@ -86,27 +91,38 @@ describe('format message', () => {
}); });
it('accepts a message id as first argument', () => { it('accepts a message id as first argument', () => {
init({ fallbackLocale: 'en' });
expect(formatMessage('form.field_1_name')).toBe('Name'); expect(formatMessage('form.field_1_name')).toBe('Name');
}); });
it('accepts a message id as first argument and formatting options as second', () => { it('accepts a message id as first argument and formatting options as second', () => {
init({ fallbackLocale: 'en' });
expect(formatMessage('form.field_1_name', { locale: 'pt' })).toBe('Nome'); expect(formatMessage('form.field_1_name', { locale: 'pt' })).toBe('Nome');
}); });
it('throws if no locale is set', () => { it('throws if no locale is set', () => {
init({ fallbackLocale: 'en' });
$locale.set(null); $locale.set(null);
expect(() => formatMessage('form.field_1_name')).toThrow( expect(() => formatMessage('form.field_1_name')).toThrow(
'[svelte-i18n] Cannot format a message without first setting the initial locale.', '[svelte-i18n] Cannot format a message without first setting the initial locale.',
); );
}); });
it('uses a missing message default value', () => { it('uses a missing message default value', () => {
init({ fallbackLocale: 'en' });
expect(formatMessage('missing', { default: 'Missing Default' })).toBe( expect(formatMessage('missing', { default: 'Missing Default' })).toBe(
'Missing Default', 'Missing Default',
); );
}); });
it('errors out when value found is not string', () => { it('errors out when value found is not string', () => {
init({ fallbackLocale: 'en' });
const spy = jest.spyOn(global.console, 'warn').mockImplementation(); const spy = jest.spyOn(global.console, 'warn').mockImplementation();
expect(typeof formatMessage('form')).toBe('object'); expect(typeof formatMessage('form')).toBe('object');
@ -117,7 +133,12 @@ describe('format message', () => {
spy.mockRestore(); spy.mockRestore();
}); });
it('warn on missing messages', () => { it('warn on missing messages if "warnOnMissingMessages" is true', () => {
init({
fallbackLocale: 'en',
warnOnMissingMessages: true,
});
const spy = jest.spyOn(global.console, 'warn').mockImplementation(); const spy = jest.spyOn(global.console, 'warn').mockImplementation();
formatMessage('missing'); formatMessage('missing');
@ -129,7 +150,18 @@ describe('format message', () => {
spy.mockRestore(); spy.mockRestore();
}); });
it('uses result of handleMissingMessage handler', () => {
init({
fallbackLocale: 'en',
handleMissingMessage: () => 'from handler',
});
expect(formatMessage('should-default')).toBe('from handler');
});
it('does not throw with invalid syntax', () => { it('does not throw with invalid syntax', () => {
init({ fallbackLocale: 'en' });
$locale.set('en'); $locale.set('en');
const spy = jest.spyOn(global.console, 'warn').mockImplementation(); const spy = jest.spyOn(global.console, 'warn').mockImplementation();