diff --git a/example/src/i18n.js b/example/src/i18n.js index 76dd0a3..349adb7 100644 --- a/example/src/i18n.js +++ b/example/src/i18n.js @@ -1,5 +1,7 @@ -import { register } from 'svelte-i18n' +import { register, setFallbackLocale } from 'svelte-i18n' register('en', () => import('../messages/en.json')) register('pt-BR', () => import('../messages/pt-BR.json')) register('es-ES', () => import('../messages/es-ES.json')) + +setFallbackLocale('en') diff --git a/package-lock.json b/package-lock.json index 88c6c7b..9a56fb0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "svelte-i18n", - "version": "2.0.1", + "version": "2.0.3", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -2502,6 +2502,11 @@ "integrity": "sha512-Dj6Wk3tWyTE+Fo1rW8v0Xhwk80um6yFYKbuAxc9c3EZxIHFDYwbi34Uk42u1CdnIiVorvt4RmlSDjIPyzGC2ew==", "dev": true }, + "dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==" + }, "doctrine": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", diff --git a/package.json b/package.json index 4201386..a8e894d 100644 --- a/package.json +++ b/package.json @@ -80,6 +80,7 @@ "dependencies": { "commander": "^4.0.1", "deepmerge": "^4.2.2", + "dlv": "^1.1.3", "estree-walker": "^0.9.0", "fast-memoize": "^2.5.1", "intl-messageformat": "^7.5.2", diff --git a/src/client/includes/lookup.ts b/src/client/includes/lookup.ts index 9f786c2..44085fb 100644 --- a/src/client/includes/lookup.ts +++ b/src/client/includes/lookup.ts @@ -1,7 +1,4 @@ -// todo invalidate only keys with null values -import resolvePath from 'object-resolve-path' - -import { hasLocaleDictionary } from '../stores/dictionary' +import { getMessageFromDictionary } from '../stores/dictionary' import { getFallbackOf } from '../stores/locale' const lookupCache: Record> = {} @@ -13,26 +10,14 @@ const addToCache = (path: string, locale: string, message: string) => { return message } -export const lookupMessage = ( - dictionary: any, - path: string, - locale: string -): string => { +export const lookupMessage = (path: string, locale: string): string => { if (locale == null) return null if (locale in lookupCache && path in lookupCache[locale]) { return lookupCache[locale][path] } - if (hasLocaleDictionary(locale)) { - if (path in dictionary[locale]) { - return addToCache(path, locale, dictionary[locale][path]) - } - const message = resolvePath(dictionary[locale], path) - if (message) return addToCache(path, locale, message) - } - return addToCache( - path, - locale, - lookupMessage(dictionary, path, getFallbackOf(locale)) - ) + const message = getMessageFromDictionary(locale, path) + if (message) return message + + return addToCache(path, locale, lookupMessage(path, getFallbackOf(locale))) } diff --git a/src/client/index.ts b/src/client/index.ts index a044001..ea022dc 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -7,7 +7,11 @@ export function defineMessages(i: Record) { return i } -export { $locale as locale, setInitialLocale } from './stores/locale' +export { + $locale as locale, + setInitialLocale, + setFallbackLocale, +} from './stores/locale' export { $dictionary as dictionary, $locales as locales, @@ -26,4 +30,3 @@ export { // @deprecated export { getClientLocale } from './includes/utils' -export { setInitialLocale as waitInitialLocale } from './stores/locale' diff --git a/src/client/stores/dictionary.ts b/src/client/stores/dictionary.ts index ba2b729..ef34c18 100644 --- a/src/client/stores/dictionary.ts +++ b/src/client/stores/dictionary.ts @@ -1,12 +1,17 @@ +import delve from 'dlv' import merge from 'deepmerge' import { writable, derived } from 'svelte/store' -import { LocaleDictionary } from '../types/index' +import { Dictionary } from '../types/index' import { getFallbackOf } from './locale' -let dictionary: LocaleDictionary -const $dictionary = writable({}) +let dictionary: Dictionary +const $dictionary = writable({}) + +export function getLocaleDictionary(locale: string) { + return (dictionary[locale] as Dictionary) || null +} export function getDictionary() { return dictionary @@ -16,14 +21,28 @@ export function hasLocaleDictionary(locale: string) { return locale in dictionary } -export function getAvailableLocale(locale: string): string | null { - if (locale in dictionary || locale == null) return locale - return getAvailableLocale(getFallbackOf(locale)) +export function getMessageFromDictionary(locale: string, id: string) { + if (hasLocaleDictionary(locale)) { + const localeDictionary = getLocaleDictionary(locale) + if (id in localeDictionary) { + return localeDictionary[id] + } + const message = delve(localeDictionary, id) + if (message) return message + } + return null } -export function addMessages(locale: string, ...partials: LocaleDictionary[]) { +export function getClosestAvailableLocale(locale: string): string | null { + if (locale == null || hasLocaleDictionary(locale)) return locale + return getClosestAvailableLocale(getFallbackOf(locale)) +} + +export function addMessages(locale: string, ...partials: Dictionary[]) { $dictionary.update(d => { - dictionary[locale] = merge.all([dictionary[locale] || {}].concat(partials)) + dictionary[locale] = merge.all( + [getLocaleDictionary(locale) || {}].concat(partials) + ) return d }) } diff --git a/src/client/stores/format.ts b/src/client/stores/format.ts index 78b4f10..ff18ea6 100644 --- a/src/client/stores/format.ts +++ b/src/client/stores/format.ts @@ -11,7 +11,7 @@ import { getNumberFormatter, } from '../includes/formats' -import { getDictionary, $dictionary } from './dictionary' +import { $dictionary } from './dictionary' import { getCurrentLocale, getFallbacksOf, $locale } from './locale' const formatMessage: Formatter = (id, options = {}) => { @@ -28,7 +28,7 @@ const formatMessage: Formatter = (id, options = {}) => { ) } - const message = lookupMessage(getDictionary(), id, locale) + const message = lookupMessage(id, locale) if (!message) { console.warn( diff --git a/src/client/stores/locale.ts b/src/client/stores/locale.ts index 4b62548..ccb65ef 100644 --- a/src/client/stores/locale.ts +++ b/src/client/stores/locale.ts @@ -4,7 +4,7 @@ import { flushQueue, hasLocaleQueue } from '../includes/loaderQueue' import { getClientLocale } from '../includes/utils' import { GetClientLocaleOptions } from '../types' -import { getAvailableLocale } from './dictionary' +import { getClosestAvailableLocale } from './dictionary' let fallbackLocale: string = null let current: string @@ -71,7 +71,7 @@ $locale.subscribe((newLocale: string) => { const localeSet = $locale.set $locale.set = (newLocale: string): void | Promise => { - if (getAvailableLocale(newLocale) && hasLocaleQueue(newLocale)) { + if (getClosestAvailableLocale(newLocale) && hasLocaleQueue(newLocale)) { return flushQueue(newLocale).then(() => localeSet(newLocale)) } return localeSet(newLocale) diff --git a/src/client/types/index.ts b/src/client/types/index.ts index c4a214c..e3bdcf4 100644 --- a/src/client/types/index.ts +++ b/src/client/types/index.ts @@ -1,5 +1,5 @@ -export interface LocaleDictionary { - [key: string]: LocaleDictionary | LocaleDictionary[] | string | object +export interface Dictionary { + [key: string]: string | string[] | Dictionary | Dictionary[] } export interface MessageObject { diff --git a/src/client/types/modules.d.ts b/src/client/types/modules.d.ts index e9a9a3a..7e8b08e 100644 --- a/src/client/types/modules.d.ts +++ b/src/client/types/modules.d.ts @@ -1,2 +1,2 @@ -declare module 'object-resolve-path' +declare module 'dlv' declare module 'nano-memoize' diff --git a/test/client/dictionary.test.ts b/test/client/dictionary.test.ts new file mode 100644 index 0000000..750272a --- /dev/null +++ b/test/client/dictionary.test.ts @@ -0,0 +1,114 @@ +import { + getDictionary, + hasLocaleDictionary, + getClosestAvailableLocale, + getMessageFromDictionary, + addMessages, + $dictionary, + $locales, + getLocaleDictionary, +} from '../../src/client/stores/dictionary' +import { get } from 'svelte/store' + +beforeEach(() => { + $dictionary.set({}) +}) + +test('adds a new dictionary to a locale', () => { + addMessages('en', { field_1: 'name' }) + addMessages('pt', { field_1: 'nome' }) + + expect(get($dictionary)).toMatchObject({ + en: { field_1: 'name' }, + pt: { field_1: 'nome' }, + }) +}) + +test('merges the existing dictionaries with new ones', () => { + addMessages('en', { field_1: 'name', deep: { prop1: 'foo' } }) + addMessages('en', { field_2: 'lastname', deep: { prop2: 'foo' } }) + addMessages('pt', { field_1: 'nome', deep: { prop1: 'foo' } }) + addMessages('pt', { field_2: 'sobrenome', deep: { prop2: 'foo' } }) + + expect(get($dictionary)).toMatchObject({ + en: { + field_1: 'name', + field_2: 'lastname', + deep: { prop1: 'foo', prop2: 'foo' }, + }, + pt: { + field_1: 'nome', + field_2: 'sobrenome', + deep: { prop1: 'foo', prop2: 'foo' }, + }, + }) +}) + +test('gets the whole current dictionary', () => { + addMessages('en', { field_1: 'name' }) + expect(getDictionary()).toMatchObject({ + en: { field_1: 'name' }, + }) +}) + +test('gets the dictionary of a locale', () => { + addMessages('en', { field_1: 'name' }) + expect(getLocaleDictionary('en')).toMatchObject({ field_1: 'name' }) +}) + +test('checks if a locale dictionary exists', () => { + addMessages('pt', { field_1: 'name' }) + expect(hasLocaleDictionary('en')).toBe(false) + expect(hasLocaleDictionary('pt')).toBe(true) +}) + +test('gets the closest available locale', () => { + addMessages('pt', { field_1: 'name' }) + expect(getClosestAvailableLocale('pt-BR')).toBe('pt') +}) + +test("returns null if there's no closest locale available", () => { + addMessages('pt', { field_1: 'name' }) + expect(getClosestAvailableLocale('it-IT')).toBe(null) +}) + +test('lists all locales in the dictionary', () => { + addMessages('en', {}) + addMessages('pt', {}) + addMessages('pt-BR', {}) + expect(get($locales)).toEqual(['en', 'pt', 'pt-BR']) +}) + +describe('getting messages', () => { + test('gets a message from a shallow dictionary', () => { + addMessages('en', { message: 'Some message' }) + expect(getMessageFromDictionary('en', 'message')).toBe('Some message') + }) + + test('gets a message from a deep object in the dictionary', () => { + addMessages('en', { messages: { message_1: 'Some message' } }) + expect(getMessageFromDictionary('en', 'messages.message_1')).toBe( + 'Some message' + ) + }) + + test('gets a message from an array in the dictionary', () => { + addMessages('en', { messages: ['Some message', 'Other message'] }) + expect(getMessageFromDictionary('en', 'messages.0')).toBe('Some message') + expect(getMessageFromDictionary('en', 'messages.1')).toBe('Other message') + }) + + test('accepts english in dictionary keys', () => { + addMessages('pt', { + 'Hey man, how are you today?': 'E ai cara, como vocĂȘ vai hoje?', + }) + expect(getMessageFromDictionary('pt', 'Hey man, how are you today?')).toBe( + 'E ai cara, como vocĂȘ vai hoje?' + ) + }) + + test('returns null for missing messages', () => { + addMessages('en', {}) + expect(getMessageFromDictionary('en', 'foo')).toBe(null) + }) +}) diff --git a/test/client/locale.test.ts b/test/client/locale.test.ts index b28f5ff..d5e5d89 100644 --- a/test/client/locale.test.ts +++ b/test/client/locale.test.ts @@ -9,9 +9,11 @@ import { $locale, isRelatedLocale, } from '../../src/client/stores/locale' +import { get } from 'svelte/store' beforeEach(() => { setFallbackLocale(undefined) + $locale.set(undefined) }) test('sets and gets the fallback locale', () => { @@ -33,7 +35,7 @@ test('checks if a locale is a fallback locale of another locale', () => { expect(isRelatedLocale('en-US', 'it')).toBe(false) }) -test('gets the next fallback locale of a certain locale', () => { +test('gets the next fallback locale of a locale', () => { expect(getFallbackOf('az-Cyrl-AZ')).toBe('az-Cyrl') expect(getFallbackOf('en-US')).toBe('en') expect(getFallbackOf('en')).toBe(null) @@ -54,13 +56,13 @@ test('if global fallback locale has a fallback, it should return it', () => { expect(getFallbackOf('en-US')).toBe('en') }) -test('gets all fallback locales of a certain locale', () => { +test('gets all fallback locales of a locale', () => { expect(getFallbacksOf('en-US')).toEqual(['en', 'en-US']) expect(getFallbacksOf('en-US')).toEqual(['en', 'en-US']) expect(getFallbacksOf('az-Cyrl-AZ')).toEqual(['az', 'az-Cyrl', 'az-Cyrl-AZ']) }) -test('gets all fallback locales of a certain locale including the global fallback locale', () => { +test('gets all fallback locales of a locale including the global fallback locale', () => { setFallbackLocale('pt') expect(getFallbacksOf('en-US')).toEqual(['en', 'en-US', 'pt']) expect(getFallbacksOf('en-US')).toEqual(['en', 'en-US', 'pt']) @@ -71,9 +73,35 @@ test('gets all fallback locales of a certain locale including the global fallbac 'pt', ]) }) +test('gets all fallback locales of a locale including the global fallback locale and its fallbacks', () => { + setFallbackLocale('pt-BR') + expect(getFallbacksOf('en-US')).toEqual(['en', 'en-US', 'pt', 'pt-BR']) + expect(getFallbacksOf('en-US')).toEqual(['en', 'en-US', 'pt', 'pt-BR']) + expect(getFallbacksOf('az-Cyrl-AZ')).toEqual([ + 'az', + 'az-Cyrl', + 'az-Cyrl-AZ', + 'pt', + 'pt-BR', + ]) +}) -test('should not list fallback locale twice', () => { +test("don't list fallback locale twice", () => { setFallbackLocale('pt-BR') expect(getFallbacksOf('pt-BR')).toEqual(['pt', 'pt-BR']) expect(getFallbacksOf('pt')).toEqual(['pt']) }) + +test('gets the current locale', () => { + expect(getCurrentLocale()).toBe(undefined) + $locale.set('es-ES') + expect(getCurrentLocale()).toBe('es-ES') +}) + +test('sets the global fallback when defining initial locale', () => { + setInitialLocale({ + fallback: 'pt', + }) + expect(get($locale)).toBe('pt') + expect(getFallbackLocale()).toBe('pt') +})