From 9dd1a19aef3496ff693fc1327c6c67e45bf253b5 Mon Sep 17 00:00:00 2001 From: Christian Kaisermann Date: Mon, 23 Nov 2020 10:50:58 -0300 Subject: [PATCH] =?UTF-8?q?feat:=20=F0=9F=8E=B8=20add=20$json=20method=20t?= =?UTF-8?q?o=20get=20raw=20dictionary=20values?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ✅ Closes: #109, #83 --- docs/Formatting.md | 54 +++++-- src/runtime/includes/lookup.ts | 12 +- src/runtime/index.ts | 1 + src/runtime/stores/formatters.ts | 31 +++- src/runtime/types/index.ts | 2 + test/runtime/includes/lookup.test.ts | 7 +- test/runtime/stores/formatters.test.ts | 204 ++++++++++++++----------- 7 files changed, 196 insertions(+), 115 deletions(-) diff --git a/docs/Formatting.md b/docs/Formatting.md index 0421a2e..2d3f858 100644 --- a/docs/Formatting.md +++ b/docs/Formatting.md @@ -3,10 +3,11 @@ - [Message syntax](#message-syntax) -- [`$format` or `$_` or `$t`](#format-or-_-or-t) +- [`$format`, `$_` or `$t`](#format-_-or-t) - [`$time(number: Date, options: MessageObject)`](#timenumber-date-options-messageobject) - [`$date(date: Date, options: MessageObject)`](#datedate-date-options-messageobject) - [`$number(number: number, options: MessageObject)`](#numbernumber-number-options-messageobject) +- [`$json(messageId: string)`](#jsonmessageid-string) - [Formats](#formats) - [Accessing formatters directly](#accessing-formatters-directly) @@ -20,7 +21,7 @@ Under the hood, `formatjs` is used for localizing your messages. It allows `svel - [Runtime Environments](https://formatjs.io/docs/guides/runtime-requirements/) - [ICU Message Syntax](https://formatjs.io/docs/core-concepts/icu-syntax/) -### `$format` or `$_` or `$t` +### `$format`, `$_` or `$t` `import { _, t, format } from 'svelte-i18n'` @@ -41,11 +42,11 @@ The formatter can be called with two different signatures: ```ts interface MessageObject { - id?: string - locale?: string - format?: string - default?: string - values?: Record + id?: string; + locale?: string; + format?: string; + default?: string; + values?: Record; } ``` @@ -56,6 +57,7 @@ interface MessageObject { - `values`: properties that should be interpolated in the message; You can pass a `string` as the first parameter for a less verbose way of formatting a message. It is also possible to inject values into the translation like so: + ```jsonc // en.json { @@ -126,6 +128,30 @@ Formats a number with the specified locale and format. Please refer to the [#for ``` +### `$json(messageId: string)` + +`import { json } from 'svelte-i18n'` + +Returns the raw JSON value of the specified `messageId` for the current locale. While [`$format`](#format-_-or-t) always returns a string, `$json` can be used to get an object relative to the current locale. + +```html +
    + {#each $json('list.items') as item} +
  • {item.name}
  • + {/each} +
+``` + +If you're using TypeScript, you can define the returned type as well: + +```html +
    + {#each $json('list.items') as item} +
  • {item.name}
  • + {/each} +
+``` + ### Formats `svelte-i18n` comes with a default set of `number`, `time` and `date` formats: @@ -163,24 +189,24 @@ import { getNumberFormatter, getTimeFormatter, getMessageFormatter, -} from 'svelte-i18n' +} from 'svelte-i18n'; ``` By using these methods, it's possible to manipulate values in a more specific way that fits your needs. For example, it's possible to create a method which receives a `date` and returns its relevant date related parts: ```js -import { getDateFormatter } from 'svelte-i18n' +import { getDateFormatter } from 'svelte-i18n'; -const getDateParts = date => +const getDateParts = (date) => getDateFormatter() .formatToParts(date) .filter(({ type }) => type !== 'literal') .reduce((acc, { type, value }) => { - acc[type] = value - return acc - }, {}) + acc[type] = value; + return acc; + }, {}); -getDateParts(new Date(2020, 0, 1)) // { month: '1', day: '1', year: '2020' } +getDateParts(new Date(2020, 0, 1)); // { month: '1', day: '1', year: '2020' } ``` Check the [methods documentation](/docs/Methods.md#low-level-api) for more information. diff --git a/src/runtime/includes/lookup.ts b/src/runtime/includes/lookup.ts index c5d149d..d0d282d 100644 --- a/src/runtime/includes/lookup.ts +++ b/src/runtime/includes/lookup.ts @@ -1,7 +1,11 @@ import { getMessageFromDictionary } from '../stores/dictionary'; import { getFallbackOf } from '../stores/locale'; -export const lookupCache: Record> = {}; +export const lookupCache: { + [locale: string]: { + [messageId: string]: any; + }; +} = {}; const addToCache = (path: string, locale: string, message: string) => { if (!message) return message; @@ -11,8 +15,8 @@ const addToCache = (path: string, locale: string, message: string) => { return message; }; -const searchForMessage = (path: string, locale: string): string => { - if (locale == null) return null; +const searchForMessage = (path: string, locale: string): any => { + if (locale == null) return undefined; const message = getMessageFromDictionary(locale, path); @@ -32,5 +36,5 @@ export const lookup = (path: string, locale: string) => { return addToCache(path, locale, message); } - return null; + return undefined; }; diff --git a/src/runtime/index.ts b/src/runtime/index.ts index e50e2ac..684f6db 100644 --- a/src/runtime/index.ts +++ b/src/runtime/index.ts @@ -39,6 +39,7 @@ export { $formatDate as date, $formatNumber as number, $formatTime as time, + $json as json, } from './stores/formatters'; // low-level diff --git a/src/runtime/stores/formatters.ts b/src/runtime/stores/formatters.ts index 00ee5ec..8ccf343 100644 --- a/src/runtime/stores/formatters.ts +++ b/src/runtime/stores/formatters.ts @@ -6,6 +6,7 @@ import { TimeFormatter, DateFormatter, NumberFormatter, + JSONGetter, } from '../types'; import { lookup } from '../includes/lookup'; import { hasLocaleQueue } from '../includes/loaderQueue'; @@ -54,23 +55,39 @@ const formatMessage: MessageFormatter = (id, options = {}) => { } message = defaultValue || id; + } else if (typeof message !== 'string') { + console.error( + `[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.`, + ); + + return message; } - if (!values) return message; + if (!values) { + return message; + } return getMessageFormatter(message, locale).format(values) as string; }; -const formatTime: TimeFormatter = (t, options) => - getTimeFormatter(options).format(t); +const formatTime: TimeFormatter = (t, options) => { + return getTimeFormatter(options).format(t); +}; -const formatDate: DateFormatter = (d, options) => - getDateFormatter(options).format(d); +const formatDate: DateFormatter = (d, options) => { + return getDateFormatter(options).format(d); +}; -const formatNumber: NumberFormatter = (n, options) => - getNumberFormatter(options).format(n); +const formatNumber: NumberFormatter = (n, options) => { + return getNumberFormatter(options).format(n); +}; + +const getJSON: JSONGetter = (id: string, locale = getCurrentLocale()) => { + return lookup(id, locale) as T; +}; export const $format = derived([$locale, $dictionary], () => formatMessage); export const $formatTime = derived([$locale], () => formatTime); export const $formatDate = derived([$locale], () => formatDate); export const $formatNumber = derived([$locale], () => formatNumber); +export const $json = derived([$locale, $dictionary], () => getJSON); diff --git a/src/runtime/types/index.ts b/src/runtime/types/index.ts index e9f8a72..ad53843 100644 --- a/src/runtime/types/index.ts +++ b/src/runtime/types/index.ts @@ -49,6 +49,8 @@ export type NumberFormatter = ( options?: IntlFormatterOptions, ) => string; +export type JSONGetter = (id: string, locale?: string) => T; + type IntlFormatterOptions = T & { format?: string; locale?: string; diff --git a/test/runtime/includes/lookup.test.ts b/test/runtime/includes/lookup.test.ts index 56f6f1e..266307b 100644 --- a/test/runtime/includes/lookup.test.ts +++ b/test/runtime/includes/lookup.test.ts @@ -9,8 +9,8 @@ beforeEach(() => { }); test('returns null if no locale was passed', () => { - expect(lookup('message.id', undefined)).toBeNull(); - expect(lookup('message.id', null)).toBeNull(); + expect(lookup('message.id', undefined)).toBeUndefined(); + expect(lookup('message.id', null)).toBeUndefined(); }); test('gets a shallow message of a locale dictionary', () => { @@ -61,6 +61,7 @@ test('gets an array ', () => { test('caches found messages by locale', () => { addMessages('en', { field: 'name' }); addMessages('pt', { field: 'nome' }); + lookup('field', 'en-US'); lookup('field', 'pt'); @@ -73,8 +74,10 @@ test('caches found messages by locale', () => { test("doesn't cache falsy messages", () => { addMessages('en', { field: 'name' }); addMessages('pt', { field: 'nome' }); + lookup('field_2', 'en-US'); lookup('field_2', 'pt'); + expect(lookupCache).not.toMatchObject({ 'en-US': { field_2: 'name' }, pt: { field_2: 'nome' }, diff --git a/test/runtime/stores/formatters.test.ts b/test/runtime/stores/formatters.test.ts index d4f8001..076d5d1 100644 --- a/test/runtime/stores/formatters.test.ts +++ b/test/runtime/stores/formatters.test.ts @@ -1,31 +1,35 @@ import { get } from 'svelte/store'; +import { + JSONGetter, + MessageFormatter, + TimeFormatter, + DateFormatter, + NumberFormatter, +} from '../../../src/runtime/types/index'; import { $format, $formatTime, $formatDate, $formatNumber, + $json, } from '../../../src/runtime/stores/formatters'; import { init } from '../../../src/runtime/configs'; import { addMessages } from '../../../src/runtime/stores/dictionary'; import { $locale } from '../../../src/runtime/stores/locale'; -import { - MessageFormatter, - TimeFormatter, - DateFormatter, - NumberFormatter, -} from '../../../src/runtime/types'; let formatMessage: MessageFormatter; let formatTime: TimeFormatter; let formatDate: DateFormatter; let formatNumber: NumberFormatter; +let getJSON: JSONGetter; $locale.subscribe(() => { formatMessage = get($format); formatTime = get($formatTime); formatDate = get($formatDate); formatNumber = get($formatNumber); + getJSON = get($json); }); addMessages('en', require('../../fixtures/en.json')); @@ -37,89 +41,113 @@ addMessages('pt-PT', require('../../fixtures/pt-PT.json')); beforeEach(() => { init({ fallbackLocale: 'en' }); }); - -test('formats a message by its id and the current locale', () => { - expect(formatMessage({ id: 'form.field_1_name' })).toBe('Name'); -}); - -test('formats a message by its id and the a passed locale', () => { - expect(formatMessage({ id: 'form.field_1_name', locale: 'pt' })).toBe('Nome'); -}); - -test('formats a message with interpolated values', () => { - expect(formatMessage({ id: 'photos', values: { n: 0 } })).toBe( - 'You have no photos.', - ); - expect(formatMessage({ id: 'photos', values: { n: 1 } })).toBe( - 'You have one photo.', - ); - expect(formatMessage({ id: 'photos', values: { n: 21 } })).toBe( - 'You have 21 photos.', - ); -}); - -test('formats the default value with interpolated values', () => { - expect( - formatMessage({ - id: 'non-existent', - default: '{food}', - values: { food: 'potato' }, - }), - ).toBe('potato'); -}); - -test('formats the key with interpolated values', () => { - expect( - formatMessage({ - id: '{food}', - values: { food: 'potato' }, - }), - ).toBe('potato'); -}); - -test('accepts a message id as first argument', () => { - expect(formatMessage('form.field_1_name')).toBe('Name'); -}); - -test('accepts a message id as first argument and formatting options as second', () => { - expect(formatMessage('form.field_1_name', { locale: 'pt' })).toBe('Nome'); -}); - -test('throws if no locale is set', () => { - $locale.set(null); - expect(() => formatMessage('form.field_1_name')).toThrow( - '[svelte-i18n] Cannot format a message without first setting the initial locale.', - ); -}); - -test('uses a missing message default value', () => { - expect(formatMessage('missing', { default: 'Missing Default' })).toBe( - 'Missing Default', - ); -}); - -test('warn on missing messages', () => { - const { warn } = global.console; - - jest.spyOn(global.console, 'warn').mockImplementation(); - - formatMessage('missing'); - - expect(console.warn).toBeCalledWith( - `[svelte-i18n] The message "missing" was not found in "en".`, - ); - - global.console.warn = warn; -}); - -describe('format utilities', () => { - it('time', () => { - expect(formatTime(new Date(2019, 0, 1, 20, 37))).toBe('8:37 PM'); +describe('format message', () => { + it('formats a message by its id and the current locale', () => { + expect(formatMessage({ id: 'form.field_1_name' })).toBe('Name'); }); - it('date', () => { - expect(formatDate(new Date(2019, 0, 1, 20, 37))).toBe('1/1/19'); + + it('formats a message by its id and the a passed locale', () => { + expect(formatMessage({ id: 'form.field_1_name', locale: 'pt' })).toBe( + 'Nome', + ); }); - it('number', () => { - expect(formatNumber(123123123)).toBe('123,123,123'); + + it('formats a message with interpolated values', () => { + expect(formatMessage({ id: 'photos', values: { n: 0 } })).toBe( + 'You have no photos.', + ); + expect(formatMessage({ id: 'photos', values: { n: 1 } })).toBe( + 'You have one photo.', + ); + expect(formatMessage({ id: 'photos', values: { n: 21 } })).toBe( + 'You have 21 photos.', + ); + }); + + it('formats the default value with interpolated values', () => { + expect( + formatMessage({ + id: 'non-existent', + default: '{food}', + values: { food: 'potato' }, + }), + ).toBe('potato'); + }); + + it('formats the key with interpolated values', () => { + expect( + formatMessage({ + id: '{food}', + values: { food: 'potato' }, + }), + ).toBe('potato'); + }); + + it('accepts a message id as first argument', () => { + expect(formatMessage('form.field_1_name')).toBe('Name'); + }); + + it('accepts a message id as first argument and formatting options as second', () => { + expect(formatMessage('form.field_1_name', { locale: 'pt' })).toBe('Nome'); + }); + + it('throws if no locale is set', () => { + $locale.set(null); + expect(() => formatMessage('form.field_1_name')).toThrow( + '[svelte-i18n] Cannot format a message without first setting the initial locale.', + ); + }); + + it('uses a missing message default value', () => { + expect(formatMessage('missing', { default: 'Missing Default' })).toBe( + 'Missing Default', + ); + }); + + it('errors out when value found is not string', () => { + const { error } = global.console; + + jest.spyOn(global.console, 'error').mockImplementation(); + + expect(typeof formatMessage('form')).toBe('object'); + expect(console.error).toBeCalledWith( + `[svelte-i18n] Message with id "form" must be of type "string", found: "object". Gettin its value through the "$format" method is deprecated; use the "json" method instead.`, + ); + + global.console.error = error; + }); + + it('warn on missing messages', () => { + const { warn } = global.console; + + jest.spyOn(global.console, 'warn').mockImplementation(); + + formatMessage('missing'); + + expect(console.warn).toBeCalledWith( + `[svelte-i18n] The message "missing" was not found in "en".`, + ); + + global.console.warn = warn; }); }); + +test('format time', () => { + expect(formatTime(new Date(2019, 0, 1, 20, 37))).toBe('8:37 PM'); +}); + +test('format date', () => { + expect(formatDate(new Date(2019, 0, 1, 20, 37))).toBe('1/1/19'); +}); + +test('format number', () => { + expect(formatNumber(123123123)).toBe('123,123,123'); +}); + +test('get raw JSON data from the current locale dictionary', () => { + expect(getJSON('form')).toMatchObject({ + field_1_name: 'Name', + field_2_name: 'Lastname', + }); + expect(getJSON('non-existing')).toBeUndefined(); +});