diff --git a/src/client/index.ts b/src/client/index.ts index 2d4a3b6..2560f7e 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -2,7 +2,15 @@ import { writable, derived } from 'svelte/store' import resolvePath from 'object-resolve-path' import memoize from 'micro-memoize' -import { capital, title, upper, lower, getClientLocale, getGenericLocaleFrom } from './utils' +import { + capital, + title, + upper, + lower, + getClientLocale, + getGenericLocaleFrom, + getGenericLocalesFrom, +} from './utils' import { MessageObject, Formatter } from './types' import { getMessageFormatter, @@ -12,41 +20,24 @@ import { } from './formatters' let currentLocale: string -let currentDictionary: Record +let currentDictionary: Record> const hasLocale = (locale: string) => locale in currentDictionary -function getAvailableLocale(locale: string): { locale: string; loader?: () => Promise } { - if (currentDictionary[locale]) { - if (typeof currentDictionary[locale] === 'function') { - return { locale, loader: currentDictionary[locale] } - } - return { locale } - } - - locale = getGenericLocaleFrom(locale) - if (locale != null) { - return getAvailableLocale(locale) - } - - return { locale: null } +function getAvailableLocale(locale: string): string | null { + if (locale in currentDictionary || locale == null) return locale + return getAvailableLocale(getGenericLocaleFrom(locale)) } const lookupMessage = memoize((path: string, locale: string): string => { - if (path in currentDictionary[locale]) { - return currentDictionary[locale][path] + if (locale == null) return null + if (hasLocale(locale)) { + if (path in currentDictionary[locale]) return currentDictionary[locale][path] + const message = resolvePath(currentDictionary[locale], path) + if (message) return message } - const message = resolvePath(currentDictionary[locale], path) - if (message == null) { - const genericLocale = getGenericLocaleFrom(locale) - if (genericLocale != null && hasLocale(genericLocale)) { - return lookupMessage(path, genericLocale) - } - return null - } - - return message + return lookupMessage(path, getGenericLocaleFrom(locale)) }) const formatMessage: Formatter = (id, options = {}) => { @@ -60,18 +51,14 @@ const formatMessage: Formatter = (id, options = {}) => { if (!message) { console.warn( - `[svelte-i18n] The message "${id}" was not found in "${locale - .split('-') - .map((_, i, arr) => arr.slice(0, i + 1).join('-')) - .reverse() - .join('", "')}".`, + `[svelte-i18n] The message "${id}" was not found in "${getGenericLocalesFrom(locale).join( + '", "', + )}".`, ) - if (defaultValue != null) return defaultValue - return id + return defaultValue || id } if (!values) return message - return getMessageFormatter(message, locale).format(values) } @@ -83,33 +70,62 @@ formatMessage.title = (id, options) => title(formatMessage(id, options)) formatMessage.upper = (id, options) => upper(formatMessage(id, options)) formatMessage.lower = (id, options) => lower(formatMessage(id, options)) -const $dictionary = writable({}) -$dictionary.subscribe((newDictionary: any) => (currentDictionary = newDictionary)) +const $dictionary = writable>>({}) +$dictionary.subscribe(newDictionary => (currentDictionary = newDictionary)) + +const loadLocale = (localeToLoad: string) => { + return Promise.all( + getGenericLocalesFrom(localeToLoad) + .map(localeItem => { + const loader = currentDictionary[localeItem] + if (loader == null && localeItem !== localeToLoad) { + console.warn( + `[svelte-i18n] No dictionary or loader were found for the locale "${localeItem}". It's the fallback locale of "${localeToLoad}."`, + ) + return + } + if (typeof loader !== 'function') return + return loader().then((dict: any) => [localeItem, dict.default || dict]) + }) + .filter(Boolean), + ) + .then(updates => { + if (updates.length > 0) { + // update dictionary only once + $dictionary.update(d => { + updates.forEach(([localeItem, localeDict]) => { + d[localeItem] = localeDict + }) + return d + }) + } + return updates + }) + .catch((e: Error) => { + throw e + }) +} const $locale = writable(null) const localeSet = $locale.set $locale.set = (newLocale: string): void | Promise => { - const { locale, loader } = getAvailableLocale(newLocale) - if (typeof loader === 'function') { - return loader() - .then((dict: any) => { - currentDictionary[locale] = dict.default || dict - if (locale) return localeSet(locale) - }) - .catch((e: Error) => { - throw e - }) + const locale = getAvailableLocale(newLocale) + if (locale) { + if (typeof currentDictionary[locale] === 'function') { + // load all locales related to the passed locale + // i.e en-GB loads en, but en doesn't load en-GB + return loadLocale(locale).then(() => localeSet(newLocale)) + } + return localeSet(newLocale) } - if (locale) return localeSet(locale) - throw Error(`[svelte-i18n] Locale "${newLocale}" not found.`) } $locale.update = (fn: (locale: string) => void | Promise) => localeSet(fn(currentLocale)) $locale.subscribe((newLocale: string) => (currentLocale = newLocale)) -const format = derived([$locale, $dictionary], () => formatMessage) -const locales = derived([$dictionary], ([$dictionary]) => Object.keys($dictionary)) +const $format = derived([$locale, $dictionary], () => formatMessage) +const $locales = derived([$dictionary], ([$dictionary]) => Object.keys($dictionary)) // defineMessages allow us to define and extract dynamic message ids const defineMessages = (i: Record) => i @@ -118,9 +134,10 @@ export { customFormats, addCustomFormats } from './formatters' export { $locale as locale, $dictionary as dictionary, - locales, + $format as _, + $format as format, + $locales, getClientLocale, defineMessages, - format as _, - format, + loadLocale as preloadLocale, } diff --git a/src/client/utils.ts b/src/client/utils.ts index 9192b44..d083a15 100644 --- a/src/client/utils.ts +++ b/src/client/utils.ts @@ -1,12 +1,15 @@ -export const capital = (str: string) => - str.replace(/(^|\s)\S/, l => l.toLocaleUpperCase()) -export const title = (str: string) => - str.replace(/(^|\s)\S/g, l => l.toLocaleUpperCase()) +export const capital = (str: string) => str.replace(/(^|\s)\S/, l => l.toLocaleUpperCase()) +export const title = (str: string) => str.replace(/(^|\s)\S/g, l => l.toLocaleUpperCase()) export const upper = (str: string) => str.toLocaleUpperCase() export const lower = (str: string) => str.toLocaleLowerCase() export function getGenericLocaleFrom(locale: string) { - return locale.length > 2 ? locale.split('-').shift() : null + const index = locale.lastIndexOf('-') + return index > 0 ? locale.slice(0, index) : null +} + +export function getGenericLocalesFrom(locale: string) { + return locale.split('-').map((_, i, arr) => arr.slice(0, i + 1).join('-')) } export const getClientLocale = ({ diff --git a/test/client/index.test.ts b/test/client/index.test.ts index e872f95..da89a17 100644 --- a/test/client/index.test.ts +++ b/test/client/index.test.ts @@ -6,6 +6,7 @@ import { getClientLocale, addCustomFormats, customFormats, + preloadLocale, } from '../../src/client' global.Intl = require('intl') @@ -14,9 +15,11 @@ let _: Formatter let currentLocale: string const dict = { - pt: require('../fixtures/pt.json'), en: require('../fixtures/en.json'), - 'en-GB': require('../fixtures/en-GB.json'), + 'en-GB': () => import('../fixtures/en-GB.json'), + pt: () => import('../fixtures/pt.json'), + 'pt-BR': () => import('../fixtures/pt-BR.json'), + 'pt-PT': () => import('../fixtures/pt-PT.json'), } format.subscribe(formatFn => { @@ -26,22 +29,15 @@ dictionary.set(dict) locale.subscribe((l: string) => { currentLocale = l }) -locale.set('pt') +locale.set('en') describe('locale', () => { it('should change locale', () => { - locale.set('pt') - expect(currentLocale).toBe('pt') locale.set('en') expect(currentLocale).toBe('en') - }) - - it('should fallback to existing locale', () => { - locale.set('pt-BR') - expect(currentLocale).toBe('pt') locale.set('en-US') - expect(currentLocale).toBe('en') + expect(currentLocale).toBe('en-US') }) it("should throw an error if locale doesn't exist", () => { @@ -59,12 +55,16 @@ describe('dictionary', () => { await locale.set('es') expect(currentLocale).toBe('es') }) + + it('load a locale and its derived locales if dictionary is a loader', async () => { + const loaded = await preloadLocale('pt-PT') + expect(loaded[0][0]).toEqual('pt') + expect(loaded[1][0]).toEqual('pt-PT') + }) }) describe('formatting', () => { it('should translate to current locale', () => { - locale.set('pt') - expect(_('switch.lang')).toBe('Trocar idioma') locale.set('en') expect(_('switch.lang')).toBe('Switch language') }) @@ -79,21 +79,22 @@ describe('formatting', () => { expect(_('batatinha.quente', { default: 'Hot Potato' })).toBe('Hot Potato') }) + it('should fallback to generic locale XX if id not found in XX-YY', async () => { + await locale.set('en-GB') + expect(_('sneakers', { locale: 'en-GB' })).toBe('trainers') + }) + it('should fallback to generic locale XX if id not found in XX-YY', () => { locale.set('en-GB') - expect(_('sneakers')).toBe('trainers') expect(_('switch.lang')).toBe('Switch language') }) it('should accept single object with id prop as the message path', () => { - locale.set('pt') - expect(_({ id: 'switch.lang' })).toBe('Trocar idioma') locale.set('en') expect(_({ id: 'switch.lang' })).toBe('Switch language') }) it('should translate to passed locale', () => { - expect(_({ id: 'switch.lang', locale: 'pt' })).toBe('Trocar idioma') expect(_('switch.lang', { locale: 'en' })).toBe('Switch language') }) @@ -101,13 +102,6 @@ describe('formatting', () => { locale.set('en') expect(_('greeting.message', { values: { name: 'Chris' } })).toBe('Hello Chris, how are you?') }) - - it('should interpolate message with variables according to passed locale', () => { - locale.set('en') - expect(_('greeting.message', { values: { name: 'Chris' }, locale: 'pt' })).toBe( - 'Olá Chris, como vai?', - ) - }) }) describe('utilities', () => { diff --git a/test/fixtures/pt-BR.json b/test/fixtures/pt-BR.json new file mode 100644 index 0000000..4394540 --- /dev/null +++ b/test/fixtures/pt-BR.json @@ -0,0 +1,3 @@ +{ + "french_bread": "pão francês" +} diff --git a/test/fixtures/pt-PT.json b/test/fixtures/pt-PT.json new file mode 100644 index 0000000..26afcc3 --- /dev/null +++ b/test/fixtures/pt-PT.json @@ -0,0 +1,3 @@ +{ + "french_bread": "cacetinhos" +}