diff --git a/.gitignore b/.gitignore index d221f13..8884f08 100644 --- a/.gitignore +++ b/.gitignore @@ -17,4 +17,5 @@ lib/ **/coverage/ **/.coverage/ **/jest-cache/ +**/.jest-cache/ **/__data__/** diff --git a/.npmignore b/.npmignore index 79a3e4d..18e4e45 100644 --- a/.npmignore +++ b/.npmignore @@ -4,7 +4,7 @@ **/coverage **/mochawesome-report **/mochawesome-reports -**/jest-cache +**/.jest-cache/ **/.github/ **/.workflow/ .travis/ diff --git a/DEV/App.jsx b/DEV/App.jsx new file mode 100644 index 0000000..ffe3def --- /dev/null +++ b/DEV/App.jsx @@ -0,0 +1,48 @@ +import React, { useEffect, useMemo } from 'react' +import I18nContext from './I18nContext' +import I18nResolver from './I18nResolver' +import transform from './transform' +import useObservableState from './useObservableState' + + +/** + * @typedef Props + * @property {import("@daniloster/i18n/lib/types").I18nState} i18n + */ + + +/** + * Application dev + * @param {Props} props + */ +export default function App({ i18n }) { + const [language] = useObservableState(i18n.language) + const [t] = useObservableState(i18n.t) + const [status] = useObservableState(i18n.status) + + useEffect(() => { + i18n.init() + }, [i18n]) + const i18nContextValue = useMemo(() => [i18n, transform], [i18n]) + + return ( + + + + i18n.languageService.set('en').catch(() => null)}>EN + i18n.languageService.set('pt').catch(() => null)}>PT + i18n.languageService.set('es').catch(() => null)}>ES + + {status} + {language} + {t('PageOne.hello')} + + + + + + + + + ) +} \ No newline at end of file diff --git a/DEV/AppForTest.jsx b/DEV/AppForTest.jsx new file mode 100644 index 0000000..381e8bf --- /dev/null +++ b/DEV/AppForTest.jsx @@ -0,0 +1,55 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react' +import I18nContext from './I18nContext' +import I18nResolver from './I18nResolver' +import transform from './transform' +import useObservableState from './useObservableState' + +/** + * @typedef Props + * @property {import("@daniloster/i18n/lib/types").I18nState} i18n + */ + +/** + * Application test + * @param {Props} props + */ +export default function AppForTest({ i18n }) { + const [language] = useObservableState(i18n.language) + const [t] = useObservableState(i18n.t) + const [status] = useObservableState(i18n.status) + const [asyncError, setAsyncError] = useState(null) + const handleDebounceError = useCallback((e) => { + if (e.debounced) { + return null + } + + setAsyncError(e) + }, []) + + useEffect(() => { + i18n.init() + }, [i18n]) + const i18nContextValue = useMemo(() => [i18n, transform], [i18n]) + + return ( + + + + i18n.languageService.set('en').catch(handleDebounceError)}>EN + i18n.languageService.set('pt').catch(handleDebounceError)}>PT + i18n.languageService.set('es').catch(handleDebounceError)}>ES + + {!!asyncError && error: {asyncError.message}} + status: {status} + language: {language} + {t('PageOne.hello')} + + + + + + + + + ) +} \ No newline at end of file diff --git a/DEV/I18nContext.js b/DEV/I18nContext.js new file mode 100644 index 0000000..d29bdec --- /dev/null +++ b/DEV/I18nContext.js @@ -0,0 +1,3 @@ +import { createContext } from 'react' + +export default createContext([null, null]) diff --git a/DEV/I18nResolver.jsx b/DEV/I18nResolver.jsx new file mode 100644 index 0000000..bc5b9c1 --- /dev/null +++ b/DEV/I18nResolver.jsx @@ -0,0 +1,23 @@ +import React from 'react' +import useI18nResolver from './useI18nResolver' + + +/** + * @typedef Props + * @property {string} namespace + * @property {string} path + * @property {{ [key: string]: any }} modifiers + */ + +/** + * + * @param {Props} props + */ +export default function I18nResolver(props) { + const { namespace, path, modifiers } = props + const t = useI18nResolver({ namespace }) + + return ( + <>{t(path, modifiers)}> + ) +} diff --git a/DEV/assets/locales/en.json b/DEV/assets/locales/en.json new file mode 100644 index 0000000..4d3341b --- /dev/null +++ b/DEV/assets/locales/en.json @@ -0,0 +1,10 @@ +{ + "PageOne": { + "hello": "Hello World", + "interpolation": { + "text": "Hey {name}! It is good to have you here. Thanks {name} for coming.", + "nodes": "Hey {name}! It is good to have you here. Thanks {name} for coming.", + "simple": "Hey {name} (awesome)!" + } + } +} \ No newline at end of file diff --git a/DEV/assets/locales/es.json b/DEV/assets/locales/es.json new file mode 100644 index 0000000..5ab43ee --- /dev/null +++ b/DEV/assets/locales/es.json @@ -0,0 +1,10 @@ +{ + "PageOne": { + "hello": "Hola Mundo", + "interpolation": { + "text": "¡Hola, {name}! Es bueno tenerte aquí. Gracias, {name} por venir.", + "nodes": "¡Hola {name}! Es bueno tenerte aquí . Gracias {name} por venir.", + "simple": "¡Hola {name} (super fantástico)!" + } + } +} \ No newline at end of file diff --git a/DEV/assets/locales/pt.json b/DEV/assets/locales/pt.json new file mode 100644 index 0000000..91d4315 --- /dev/null +++ b/DEV/assets/locales/pt.json @@ -0,0 +1,10 @@ +{ + "PageOne": { + "hello": "Ola Mundo", + "interpolation": { + "text": "Ei, {name}! É bom ter você aqui. Obrigado {name} por ter vindo.", + "nodes": "Ei, {name}! É bom tê-lo aqui . Obrigado {name} por ter vindo.", + "simple": "Ei {name} (super fantástico)!" + } + } +} \ No newline at end of file diff --git a/DEV/dev.d.ts b/DEV/dev.d.ts new file mode 100644 index 0000000..e69de29 diff --git a/DEV/factoryI18n.js b/DEV/factoryI18n.js new file mode 100644 index 0000000..69ebbae --- /dev/null +++ b/DEV/factoryI18n.js @@ -0,0 +1,24 @@ +import factoryI18n from '@daniloster/i18n' + +/** + * @returns {import('@daniloster/i18n/lib/types').I18nState} + */ +export default (props) => factoryI18n({ + defaultLanguage: 'en', + languageService: { + get: async () => localStorage.getItem('language'), + set: async (language) => localStorage.setItem('language', language), + }, + resourcesService: { + get: async (language) => { + const response = await fetch(`/assets/locales/${language}.json`) + if (response.ok) { + return await response.json() + } + + throw new Error(`[ERROR]: ${response.status} | ${response.statusText}`) + }, + set: async () => null + }, + ...props, +}) diff --git a/DEV/i18n.js b/DEV/i18n.js new file mode 100644 index 0000000..2141b94 --- /dev/null +++ b/DEV/i18n.js @@ -0,0 +1,16 @@ +import factoryI18n from './factoryI18n' + +export default factoryI18n({ + defaultLanguage: 'en', + languageService: { + get: async () => localStorage.getItem('language'), + set: async (language) => localStorage.setItem('language', language), + }, + resourcesService: { + get: async (language) => { + const response = await fetch(`/assets/locales/${language}.json`) + return await response.json() + }, + set: async () => Promise.resolve(null) + }, +}) diff --git a/DEV/index.jsx b/DEV/index.jsx new file mode 100644 index 0000000..3c9488d --- /dev/null +++ b/DEV/index.jsx @@ -0,0 +1,11 @@ +import '@babel/polyfill' +import React from 'react' +import { render } from 'react-dom' +import App from './App' +import i18n from './i18n' + + +const root = document.createElement('div') +document.body.appendChild(root) + +render(, root) diff --git a/DEV/transform.js b/DEV/transform.js new file mode 100644 index 0000000..2a1caed --- /dev/null +++ b/DEV/transform.js @@ -0,0 +1,9 @@ +function interpolate(resolution, [key, value]) { + return resolution.replace(new RegExp(`{${key}}`, 'g'), value) +} + +export default function transform({ template, modifiers }) { + return Object + .entries(modifiers || {}) + .reduce(interpolate, template) +} diff --git a/DEV/useI18nResolver.jsx b/DEV/useI18nResolver.jsx new file mode 100644 index 0000000..2f70402 --- /dev/null +++ b/DEV/useI18nResolver.jsx @@ -0,0 +1,36 @@ +import { useCallback, useContext } from 'react' +import I18nContext from './I18nContext' +import useObservableState from "./useObservableState" + + +/** + * @typedef Props + * @property {string} namespace + */ + +/** + * + * @param {Props} props + */ +export default function useI18nResolver(props) { + const { namespace } = props + /** @type {[import('@daniloster/i18n/lib/types').I18nState, import('@daniloster/i18n/lib/types').ResolverTransformer]} */ + const [i18n, transformer] = useContext(I18nContext) + const [t] = useObservableState(i18n.t) + + return useCallback((path, modifiers) => { + /** @type {import('@daniloster/i18n/lib/types').ResolverTransformer} */ + const interpolate = (options) => { + if (transformer) { + return transformer({ ...options, modifiers }) + } + + return options.template || options.path + } + + return t( + namespace ? namespace + '.' + path : path, + transformer ? interpolate : transformer, + ) + }, [t, transformer]) +} diff --git a/DEV/useObservableState.js b/DEV/useObservableState.js new file mode 100644 index 0000000..2fe6fbd --- /dev/null +++ b/DEV/useObservableState.js @@ -0,0 +1,26 @@ +import { useEffect, useMemo, useState } from 'react' + +/** + * Listen to an observable state converting into react hook + * @param {import('@daniloster/i18n/lib/types').ObservableState} observableState + * @returns {[S, ((value: S) => S) => void]} + * @template S + */ +export default function useObservableState(observableState) { + /** @type {[S, React.Dispatch>]} */ + const state = useState(() => observableState.get()) + const [value, setValue] = state + + useEffect(() => { + const subscription = observableState.subscribe({ + next: (newValue) => { + setValue((oldValue) => oldValue !== newValue ? newValue : oldValue) + } + }) + + return subscription.unsubscribe + }, [observableState]) + + /** @type {[S, ((value: S) => S) => void]} */ + return useMemo(() => [value, observableState.set], [value, observableState.set]) +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..0c30297 --- /dev/null +++ b/README.md @@ -0,0 +1,32 @@ +# @daniloster/i18n + + + +## Contents + +- [Getting Started](https://github.com/daniloster/i18n/blob/master/docs/GETTING_STARTED.md) +- [Contributions](#Contributions) + - [Steps](#Steps) + - [Dev](#Dev) + +## IE Polyfills + +- [string.prototype.matchall](https://www.npmjs.com/package/string.prototype.matchall) + +## Contributions + +Feel free to reach out and help improving this library. + +### Steps + +- Pull the repository `git clone https://github.com/daniloster/i18n.git` +- Creating your contribution +- Add at least one commit with message containing either `[patch]`, `[minor]` or `[major]` +- Add at least one commit with message containing `[release]`, otherwise, it won't be released. +- Do not use `git pull`, go for `git fetch` and `git rebase` +- Add tests as "the changes are intended to be used by dev" + +### Dev + +- **Testing**: `yarn test` or `yarn test --watch` +- **Web Dev App**: `yarn dev` diff --git a/docs/GETTING_STARTED.md b/docs/GETTING_STARTED.md new file mode 100644 index 0000000..db10c29 --- /dev/null +++ b/docs/GETTING_STARTED.md @@ -0,0 +1,3 @@ +# Getting Started + +`@daniloster/i18n` provides a simple abstraction layer to manage internationalization. diff --git a/jest.config.js b/jest.config.js index 508c040..529d937 100644 --- a/jest.config.js +++ b/jest.config.js @@ -2,7 +2,7 @@ process.env.TZ = 'UTC' process.env.NODE_ICU_DATA = 'node_modules/full-icu' module.exports = { - cacheDirectory: './jest-cache', + cacheDirectory: './.jest-cache', coverageReporters: ['html', 'json', 'lcov', 'text'], coverageDirectory: '/coverage/', collectCoverage: true, diff --git a/package.json b/package.json index 1124785..6559b2d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@daniloster/i18n", - "version": "0.0.1", + "version": "0.0.0", "description": "Library to apply internationalization for node or javascript projects", "main": "lib/index.js", "types": "lib/types.d.ts", @@ -74,6 +74,7 @@ "mutation-helper": "1.0.0" }, "resolutions": { - "jsdom": "16.2.2" + "jsdom": "16.2.2", + "lodash": "4.17.19" } } diff --git a/src/Application.test.jsx b/src/Application.test.jsx new file mode 100644 index 0000000..e1e3d5b --- /dev/null +++ b/src/Application.test.jsx @@ -0,0 +1,169 @@ +import { act, fireEvent, render } from '@testing-library/react' +import { Response } from 'miragejs' +import React from 'react' +import AppForTest from '../DEV/AppForTest' +import factoryI18n from '../DEV/factoryI18n' +import delay from '../tools/helpers/tests/delay' + +/** + * + * @param {any} props + * @returns {[import('@testing-library/react').RenderResult, import('@daniloster/i18n/lib/types').I18nState]} + */ +function renderComponent(props = {}) { + const i18n = factoryI18n(props) + return [render(), i18n] +} + +describe('Application', () => { + test('if Application renders the default language (en)', async () => { + const [wrapper] = renderComponent() + expect(await wrapper.findByText('status: Success', {}, { timeout: 5000 })).toBeTruthy() + expect(await wrapper.findByText('language: en')).toBeTruthy() + const helloWorlds = await wrapper.findAllByText('Hello World') + expect(helloWorlds).toBeTruthy() + expect(helloWorlds).toHaveLength(2) + + expect(await wrapper.findByText('Hey {name}! It is good to have you here. Thanks {name} for coming.')).toBeTruthy() + expect(await wrapper.findByText('Hey {name}! It is good to have you here. Thanks {name} for coming.')).toBeTruthy() + }, 10000) + + test('if Application renders the stored language (pt)', async () => { + localStorage.setItem('language', 'pt') + const [wrapper] = renderComponent() + expect(await wrapper.findByText('status: Success', {}, { timeout: 5000 })).toBeTruthy() + expect(await wrapper.findByText('language: pt')).toBeTruthy() + const helloWorlds = await wrapper.findAllByText('Ola Mundo', {}, { timeout: 5000 }) + expect(helloWorlds).toBeTruthy() + expect(helloWorlds).toHaveLength(2) + + expect(await wrapper.findByText('Ei, {name}! É bom ter você aqui. Obrigado {name} por ter vindo.')).toBeTruthy() + expect(await wrapper.findByText('Ei, {name}! É bom tê-lo aqui . Obrigado {name} por ter vindo.')).toBeTruthy() + }, 10000) + + test('if Application is notified when load language after initialization', async () => { + const [wrapper, i18n] = renderComponent() + expect(await wrapper.findByText('status: Success', {}, { timeout: 5000 })).toBeTruthy() + expect(await wrapper.findByText('language: en')).toBeTruthy() + + localStorage.setItem('language', 'es') + const next = jest.fn() + i18n.language.subscribe({ next }) + await act(async () => { + await i18n.languageService.get() + }) + + expect(next).toHaveBeenCalled() + expect(next).toHaveBeenCalledTimes(1) + expect(next).toHaveBeenCalledWith('es') + + // request is made to not affect next test + await delay(1000) + }, 10000) + + test('if Application is forced to not notify when load language', async () => { + const [wrapper, i18n] = renderComponent() + expect(await wrapper.findByText('status: Success', {}, { timeout: 5000 })).toBeTruthy() + expect(await wrapper.findByText('language: en')).toBeTruthy() + + localStorage.setItem('language', 'es') + const next = jest.fn() + i18n.language.subscribe({ next }) + await act(async () => { + await i18n.languageService.get(true) + }) + + expect(next).not.toHaveBeenCalled() + + // request is made to not affect next test + await delay(1000) + }, 10000) + + test('if Application do not fetch loaded resource', async () => { + /** + * For this test, we are going to try against the default language + */ + localStorage.setItem('language', 'pt') + const [wrapper, i18n] = renderComponent() + expect(await wrapper.findByText('status: Success', {}, { timeout: 5000 })).toBeTruthy() + expect(await wrapper.findByText('language: pt')).toBeTruthy() + + const helloWorlds = await wrapper.findAllByText('Hello World') + expect(helloWorlds).toBeTruthy() + expect(helloWorlds).toHaveLength(2) + + const next = jest.fn() + i18n.resources.subscribe({ next }) + const en = await wrapper.findByTestId('EN') + fireEvent.click(en) + await delay(1000) + + expect(next).not.toHaveBeenCalled() + }, 10000) + + test('if Application changes language with debounce', async () => { + const [wrapper] = renderComponent() + expect(await wrapper.findByText('status: Success', {}, { timeout: 5000 })).toBeTruthy() + expect(await wrapper.findByText('language: en')).toBeTruthy() + const helloWorlds = await wrapper.findAllByText('Hello World') + expect(helloWorlds).toBeTruthy() + expect(helloWorlds).toHaveLength(2) + + const pt = await wrapper.findByTestId('PT') + + fireEvent.click(pt) + await delay(100) + fireEvent.click(pt) + await delay(100) + fireEvent.click(pt) + await delay(100) + + expect(global.server.pretender.handledRequests).toHaveLength(1) + await delay(1500) + const requests = global.server.pretender.handledRequests + expect(requests).toHaveLength(2) + expect(requests[0].responseURL).toEqual('/assets/locales/en.json') + expect(requests[1].responseURL).toEqual('/assets/locales/pt.json') + }, 10000) + + test('if Application changes language', async () => { + const [wrapper] = renderComponent() + expect(await wrapper.findByText('status: Success', {}, { timeout: 5000 })).toBeTruthy() + expect(await wrapper.findByText('language: en')).toBeTruthy() + const helloWorlds = await wrapper.findAllByText('Hello World') + expect(helloWorlds).toBeTruthy() + expect(helloWorlds).toHaveLength(2) + + const pt = await wrapper.findByTestId('PT') + + fireEvent.click(pt) + await delay(1000) + + expect(await wrapper.findByText('status: Success', {}, { timeout: 5000 })).toBeTruthy() + expect(await wrapper.findByText('language: pt')).toBeTruthy() + + await delay(3000) + + const olaMundos = await wrapper.findAllByText('Ola Mundo', {}, { timeout: 5000 }) + expect(olaMundos).toBeTruthy() + expect(olaMundos).toHaveLength(2) + }, 20000) + + test('if Application handler error on changing language', async () => { + const [wrapper] = renderComponent() + expect(await wrapper.findByText('status: Success', {}, { timeout: 5000 })).toBeTruthy() + expect(await wrapper.findByText('language: en')).toBeTruthy() + const helloWorlds = await wrapper.findAllByText('Hello World') + expect(helloWorlds).toBeTruthy() + expect(helloWorlds).toHaveLength(2) + + const pt = await wrapper.findByTestId('PT') + + global.server.get('/:language', () => new Response(500)) + + fireEvent.click(pt) + await delay(1000) + + expect(await wrapper.findByText('status: Error', {}, { timeout: 5000 })).toBeTruthy() + }, 10000) +}) diff --git a/src/AsyncStatus.js b/src/AsyncStatus.js new file mode 100644 index 0000000..4f7f282 --- /dev/null +++ b/src/AsyncStatus.js @@ -0,0 +1,6 @@ +export default { + Initial: 'Initial', + Loading: 'Loading', + Success: 'Success', + Error: 'Error', +} diff --git a/src/ObservableState.js b/src/ObservableState.js new file mode 100644 index 0000000..77d5719 --- /dev/null +++ b/src/ObservableState.js @@ -0,0 +1,46 @@ +class Subject { + constructor() { + this.subscribers = [] + } + + next(value) { + this.subscribers.forEach((subscriber) => { + subscriber.next(value) + }) + } + + subscribe(subscriber) { + const self = this + self.subscribers.push(subscriber) + + return { + unsubscribe: () => { + self.subscribers = self.subscribers.filter((s) => s !== subscriber) + }, + } + } +} + +export default { + /** + * @param {T} initialValue + * @returns {import('@daniloster/i18n/lib/types').ObservableState} + * @template T + */ + create: (initialValue) => { + let value = initialValue + const subject = new Subject() + + return { + get: () => value, + set: (transformer) => { + const newValue = transformer(value) + if (value !== newValue) { + value = newValue + subject.next(value) + } + }, + subscribe: (subscriber) => subject.subscribe(subscriber), + } + }, +} diff --git a/src/ObservableState.test.js b/src/ObservableState.test.js new file mode 100644 index 0000000..e49c28d --- /dev/null +++ b/src/ObservableState.test.js @@ -0,0 +1,101 @@ +import { act, fireEvent, render } from '@testing-library/react' +import PropTypes from 'prop-types' +import React from 'react' +import useObservableState from '../DEV/useObservableState' +import ObservableState from './ObservableState' + +function factorySharedState() { + const calcState = ObservableState.create({ + number: 1, + }) + + function useCalcState() { + return useObservableState(calcState) + } + + return [useCalcState, calcState] +} + +function Number({ prefix, useShareState }) { + const [state, setState] = useShareState() + return ( + <> + + {prefix}: {state.number} + + { + setState(({ number }) => ({ + number: (prefix.includes('1st') ? 10 : 20) + number, + })) + }} + > + Increment {prefix} + + > + ) +} +Number.propTypes = { + prefix: PropTypes.string.isRequired, + useShareState: PropTypes.func.isRequired, +} + +function Wrapper({ useShareState }) { + return ( + + + + + ) +} +Wrapper.propTypes = { + useShareState: PropTypes.func.isRequired, +} + +describe('useObservableState', () => { + test('if useObservableState share state', () => { + const [useShareState] = factorySharedState() + const wrapper = render() + + expect(wrapper.getByText(/1st number: 1/i)).toBeInTheDocument() + expect(wrapper.getByText(/2nd number: 1/i)).toBeInTheDocument() + }) + + test('if useObservableState share state changed from outside', () => { + const [useShareState, sharedState] = factorySharedState() + const wrapper = render() + + act(() => { + sharedState.set((state) => ({ number: state.number + 100 })) + }) + expect(wrapper.getByText(/1st number: 101/i)).toBeInTheDocument() + expect(wrapper.getByText(/2nd number: 101/i)).toBeInTheDocument() + }) + + test('if useObservableState share state changed from first internal component', async () => { + const [useShareState] = factorySharedState() + const wrapper = render() + + fireEvent.click(await wrapper.getByTestId('id-1st-Number')) + expect(await wrapper.findByText(/1st number: 11/i)).toBeInTheDocument() + expect(await wrapper.findByText(/2nd number: 11/i)).toBeInTheDocument() + }) + + test('if useObservableState share state changed from first internal component', async () => { + const [useShareState] = factorySharedState() + const wrapper = render() + + fireEvent.click(await wrapper.getByTestId('id-2nd-Number')) + expect(await wrapper.findByText(/1st number: 21/i)).toBeInTheDocument() + expect(await wrapper.findByText(/2nd number: 21/i)).toBeInTheDocument() + }) + + test('if ObservableState does not change after return current state', async () => { + const initialState = { name: 'The Same' } + const state = ObservableState.create(initialState) + state.set((val) => val) + expect(state.get()).toBe(initialState) + }) +}) diff --git a/src/factoryAsyncDebounce.js b/src/factoryAsyncDebounce.js new file mode 100644 index 0000000..c26581b --- /dev/null +++ b/src/factoryAsyncDebounce.js @@ -0,0 +1,36 @@ +/** + * Creates a debounced async function + * @param {(...args) => Promise} fn + * @param {import('@daniloster/i18n/lib/types').AsyncDebounceHooks} statusHooks + * @param {number} debounce + * @returns {import('@daniloster/i18n/lib/types').Debounced>} + * @template T + */ +export default function factoryAsyncDebounce(fn, statusHooks) { + let ref = null + let lastReject = () => null + const debounced = (...args) => new Promise((resolve, reject) => { + clearTimeout(ref) + lastReject({ debounced: true }) + lastReject = reject + statusHooks.onStart() + /** @type {boolean} */ + const skip = statusHooks.skip(...args) + const debouncedExecutor = () => { + fn(...args) + .then((...response) => { + resolve(...response) + statusHooks.onSuccess() + }).catch((...error) => { + statusHooks.onError() + reject(...error) + }) + } + + ref = setTimeout(debouncedExecutor, skip ? 0 : statusHooks.debounce) + }) + + debounced.bypass = fn + + return debounced +} diff --git a/src/factoryI18n.js b/src/factoryI18n.js new file mode 100644 index 0000000..6bfbdb6 --- /dev/null +++ b/src/factoryI18n.js @@ -0,0 +1,98 @@ +import AsyncStatus from "./AsyncStatus" +import factoryLanguageService from "./factoryLanguageService" +import factoryResolver from "./factoryResolver" +import factoryResourcesService from "./factoryResourcesService" +import ObservableState from "./ObservableState" +import onInit from "./onInit" + +const DEFAULT_DEBOUNCE = 300 +/** + * Validates if a real number is passed as debounce otherwise returns + * a default debounce (300) + * @param {number} debounce the delay time in milliseconds (optional) + * @returns {number} the delay time in milliseconds + */ +function getDebounce(debounce) { + return typeof debounce === 'number' ? debounce : DEFAULT_DEBOUNCE +} + +/** + * Creates i18n object which manages internationalization asynchronously. + * @param {import('@daniloster/i18n/lib/types').I18nConfig} config + * @returns {import('@daniloster/i18n/lib/types').I18nState} + */ +export default function factoryI18n(config) { + const { + debounceLanguageService, + debounceResourcesService, + defaultLanguage, + } = config + /** @type {import('@daniloster/i18n/lib/types').ObservableState} */ + const language = ObservableState.create(defaultLanguage) + /** @type {import('@daniloster/i18n/lib/types').ObservableState} */ + const resources = ObservableState.create({}) + + const languageService = factoryLanguageService( + config.languageService, + language, + resources, + getDebounce(debounceLanguageService) + ) + const resourcesService = factoryResourcesService( + config.resourcesService, + resources, + getDebounce(debounceResourcesService) + ) + language.subscribe({ + next: (lng) => { + // Errors will be captured by statusObservableList listeners + resourcesService.get(lng).catch(() => null) + } + }) + const status = ObservableState.create(AsyncStatus.Initial) + const statusObservableList = [ + languageService.status.get, + languageService.status.set, + resourcesService.status.get, + resourcesService.status.set, + ] + statusObservableList.forEach(statusObservable => { + statusObservable.subscribe({ + next: () => { + if (statusObservableList.some(currentStatus => currentStatus.get() === AsyncStatus.Loading)) { + status.set(() => AsyncStatus.Loading) + } else if (statusObservableList.some(currentStatus => currentStatus.get() === AsyncStatus.Error)) { + status.set(() => AsyncStatus.Error) + } else { + status.set(() => AsyncStatus.Success) + } + } + }) + }) + + const t = factoryResolver({ + defaultLanguage, + language, + resources, + status, + }) + + async function init() { + await onInit({ + defaultLanguage, + languageService, + resourcesService, + status, + }) + } + + return { + init, + languageService, + resourcesService, + language, + resources, + status, + t, + } +} diff --git a/src/factoryLanguageService.js b/src/factoryLanguageService.js new file mode 100644 index 0000000..312abc9 --- /dev/null +++ b/src/factoryLanguageService.js @@ -0,0 +1,43 @@ +import AsyncStatus from "./AsyncStatus" +import factoryAsyncDebounce from "./factoryAsyncDebounce" +import factoryStatusHooks from "./factoryStatusHooks" +import ObservableState from "./ObservableState" + +/** + * + * @param {import('@daniloster/i18n/lib/types').LanguageService} languageService + * @param {import('@daniloster/i18n/lib/types').ObservableState} languageState + * @param {import('@daniloster/i18n/lib/types').ObservableState} resourceState + * @param {number} debounce + * @returns {import('@daniloster/i18n/lib/types').LanguageServiceObservable} + */ +export default function factoryLanguageService(languageService, languageState, resourceState, debounce) { + const status = { + get: ObservableState.create(AsyncStatus.Initial), + set: ObservableState.create(AsyncStatus.Initial), + } + + function isResourceLoaded(language) { + return !!resourceState.get()[language] + } + + /** @type {import('@daniloster/i18n/lib/types').LanguageServiceObservable} */ + const service = { + status, + get: factoryAsyncDebounce(async (bypassNotification) => { + const language = await languageService.get() + if (!bypassNotification) { + languageState.set(() => language) + } + + return language + }, factoryStatusHooks(status.get, () => false, debounce)), + set: factoryAsyncDebounce(async (language) => { + const response = await languageService.set(language) + languageState.set(() => language) + + return response + }, factoryStatusHooks(status.set, isResourceLoaded, debounce)), + } + return Object.freeze(service) +} \ No newline at end of file diff --git a/src/factoryResolver.js b/src/factoryResolver.js new file mode 100644 index 0000000..7b5e927 --- /dev/null +++ b/src/factoryResolver.js @@ -0,0 +1,98 @@ +import { get as getPath } from 'mutation-helper' +import ObservableState from './ObservableState' + +function get(obj, path) { + try { + return getPath(obj, path) + } catch { + return null + } +} + +/** + * + * @param {import('@daniloster/i18n/lib/types').Resource} resources + * @param {string} language + * @param {AsyncStatus} status + * @returns {(path: string, transform: (string) => string) => string} + */ +function factoryTemplateResolver(resources, language, status, defaultResolver) { + /** + * Resolves the json path into a string template that can be transformed + * before returning + * @param {string} path + * @param {(string) => string} transform + * @returns {string} + */ + return (path, transform) => { + const dictionary = get(resources, language) + const template = get(dictionary, path) + if (template === null || template === undefined) { + if (defaultResolver) { + const defaultTemplate = defaultResolver(path, transform) + /** + * Fallback to the default language. In case no value is found, + * it will return the proper path, then, when defaultTemplate !== path + * it means it got resolved. + */ + if (defaultTemplate !== path) { + return defaultTemplate + } + } + + return path + } + + if (transform) { + return transform({ path, template, status }) + } + + return template + } +} + +/** + * Creates a resolver for i18n key + * @param {import('@daniloster/i18n/lib/types').ResolverConfig} props + */ +export default function factoryResolver(props) { + const { + defaultLanguage, + language, + resources, + status, + } = props + + let [rs, lng, st] = [resources.get(), language.get(), status.get()] + + const getDefaultResolver = () => factoryTemplateResolver(resources.get(), defaultLanguage, status.get()) + + const resolver = ObservableState.create(factoryTemplateResolver(rs, lng, st)) + const factory = () => { + resolver.set(() => factoryTemplateResolver(rs, lng, st, getDefaultResolver())) + } + + status.subscribe({ + next: (_st) => { + st = _st + factory() + } + }) + language.subscribe({ + next: (_lng) => { + if (rs[_lng]) { + lng = _lng + } + factory() + } + }) + resources.subscribe({ + next: (_rs) => { + rs = _rs + lng = language.get() + factory() + } + }) + + return resolver +} \ No newline at end of file diff --git a/src/factoryResourcesService.js b/src/factoryResourcesService.js new file mode 100644 index 0000000..6f2589c --- /dev/null +++ b/src/factoryResourcesService.js @@ -0,0 +1,70 @@ +import AsyncStatus from "./AsyncStatus" +import factoryAsyncDebounce from "./factoryAsyncDebounce" +import factoryStatusHooks from "./factoryStatusHooks" +import ObservableState from "./ObservableState" + +/** + * + * @param {import('@daniloster/i18n/lib/types').ResourcesService} resourcesService + * @param {import('@daniloster/i18n/lib/types').ObservableState} resourceState + * @param {number} debounce + * @returns {import('@daniloster/i18n/lib/types').ResourcesServiceObservable} + */ +export default function factoryResourcesService(resourcesService, resourceState, debounce) { + /** + * @type {{ + * get: import('@daniloster/i18n/lib/types').ObservableState, + * set: import('@daniloster/i18n/lib/types').ObservableState + * }} + * */ + const status = { + get: ObservableState.create(AsyncStatus.Initial), + set: ObservableState.create(AsyncStatus.Initial), + } + + function isResourceLoaded(language) { + return !!resourceState.get()[language] + } + + /** @type {import('@daniloster/i18n/lib/types').ResourcesServiceObservable} */ + const service = { + status, + get: factoryAsyncDebounce(async (language) => { + const loadedResource = resourceState.get()[language] + if (loadedResource) { + return loadedResource + } + + const resource = await resourcesService.get(language) + resourceState.set(oldResources => { + if (!oldResources[language]) { + return { + ...oldResources, + [language]: resource + } + } + + return oldResources + }) + + return resource + }, factoryStatusHooks(status.get, isResourceLoaded, debounce)), + set: factoryAsyncDebounce(async (language, resource) => { + const response = await resourcesService.set(language, resource) + resourceState.set(oldResources => { + if (!oldResources[language]) { + return { + ...oldResources, + [language]: resource + } + } + + return oldResources + }) + + return response + }, factoryStatusHooks(status.set, () => false, debounce)), + } + + return Object.freeze(service) +} \ No newline at end of file diff --git a/src/factoryStatusHooks.js b/src/factoryStatusHooks.js new file mode 100644 index 0000000..a6dec1f --- /dev/null +++ b/src/factoryStatusHooks.js @@ -0,0 +1,22 @@ +import AsyncStatus from "./AsyncStatus" + +/** + * Creates a status hook + * @param {import('@daniloster/i18n/lib/types').ObservableState} observable + * @param {Function(args): boolean} skip + * @param {number} debounce + * @returns {import('@daniloster/i18n/lib/types').AsyncDebounceHooks} + */ +export default function factoryStatusHooks(observable, skip, debounce) { + const onStart = () => observable.set(() => AsyncStatus.Loading) + const onError = () => observable.set(() => AsyncStatus.Error) + const onSuccess = () => observable.set(() => AsyncStatus.Success) + + return { + debounce, + skip, + onStart, + onError, + onSuccess, + } +} diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..61c306e --- /dev/null +++ b/src/index.js @@ -0,0 +1,3 @@ +import factoryI18n from './factoryI18n' + +export default factoryI18n diff --git a/src/onInit.js b/src/onInit.js new file mode 100644 index 0000000..727d6a5 --- /dev/null +++ b/src/onInit.js @@ -0,0 +1,51 @@ +import AsyncStatus from "./AsyncStatus" + +// make sure won't trigger notification by getting language +const BypassNotification = true + +/** + * + * @typedef InitConfig + * @property {string} defaultLanguage + * @property {import('@daniloster/i18n/lib/types').LanguageServiceObservable} languageService + * @property {import('@daniloster/i18n/lib/types').ResourcesServiceObservable} resourcesService + * @property {import('@daniloster/i18n/lib/types').ObservableState} status + */ + +/** + * + * @param {InitConfig} config + */ +export default async function onInit(config) { + const { + defaultLanguage, + languageService, + resourcesService, + status, + } = config + // start on loading + status.set(() => AsyncStatus.Loading) + try { + const remoteLanguage = await languageService.get.bypass(BypassNotification) + // define initial language + const initialLanguage = remoteLanguage || defaultLanguage + /** + * set new language if initial different from default. This + * will trigger load on demand + */ + if (initialLanguage !== defaultLanguage) { + await resourcesService.get.bypass(defaultLanguage) + await languageService.set.bypass(initialLanguage) + status.set(() => AsyncStatus.Success) + } else { + /** + * otherwise, force load of default language + */ + await resourcesService.get.bypass(initialLanguage) + status.set(() => AsyncStatus.Success) + } + } catch (e) { + status.set(() => AsyncStatus.Error) + throw e + } +} \ No newline at end of file diff --git a/src/types.d.ts b/src/types.d.ts new file mode 100644 index 0000000..8962a38 --- /dev/null +++ b/src/types.d.ts @@ -0,0 +1,111 @@ +interface Debounced { + (...args): R + + bypass(...args): R +} + +type Resource = { [key: string]: string | number | Date | boolean | Resource } + +export interface ResourcesService { + get(language: string): Promise + set(language: string, resource: Resource): Promise +} + +export interface ResourcesServiceObservable extends ResourcesService { + status: { + get: ObservableState + set: ObservableState + } +} + +export interface LanguageService { + get(): Promise + set(language: string): Promise +} + +export interface LanguageServiceObservable extends LanguageService { + status: { + get: ObservableState + set: ObservableState + } +} + +export interface Init { + (): Promise +} + +export interface I18nConfig { + defaultLanguage: string + debounceLanguageService?: number + debounceResourcesService?: number + languageService: LanguageService + resourcesService: ResourcesService +} + +export type ResolverTransformerInput = { path: string; template: string; status: AsyncStatus } + +export interface ResolverTransformer { + (arg: ResolverTransformerInput): string +} + +interface Resolver { + (path: string, transformer: ResolverTransformer): string +} + +export interface I18nState { + init: Promise + languageService: LanguageService + language: ObservableState + resourcesService: ResourcesService + resources: ObservableState + status: ObservableState + t: ObservableState +} + +export interface Subscriber { + next(value: T): void +} + +export interface Subscription { + unsubscribe(): void +} + +export interface Subject extends Subscriber { + private subscribers: Subscriber[] + subscribe(subscriber: Subscriber): Subscription +} + +export interface TransformerState { + (state: T): T +} + +export interface ObservableState { + get: () => T, + set: (transformer: TransformerState) => void + subscribe: (subscriber: Subscriber) => Subscription +} + +export interface FactoryObservableState { + create(initialValue: T): ObservableState +} + +export interface AsyncDebounceHooks { + debounce: number + onStart: Function + onError: Function + onSuccess: Function +} + +enum AsyncStatus { + Initial = 'Initial', + Loading = 'Loading', + Success = 'Success', + Error = 'Error', +} + +export interface ResolverConfig { + defaultLanguage: string + language: ObservableState + resources: ObservableState + status: ObservableState +} diff --git a/tools/__mocks__/startMirage.js b/tools/__mocks__/startMirage.js index dc0d8eb..c5f916c 100644 --- a/tools/__mocks__/startMirage.js +++ b/tools/__mocks__/startMirage.js @@ -1,9 +1,9 @@ import { Server } from 'miragejs' import en from '../../DEV/assets/locales/en.json' import es from '../../DEV/assets/locales/es.json' -import ptBR from '../../DEV/assets/locales/pt.json' +import pt from '../../DEV/assets/locales/pt.json' -const resources = { 'en.json': en, 'pt-BR.json': ptBR, 'es.json': es } +const resources = { 'en.json': en, 'pt.json': pt, 'es.json': es } /** * Creates a mock api server which mimics the api service behaviour @@ -34,7 +34,7 @@ export default function startMirage(environment = 'development') { (schema, request) => { const { language } = request.params - // console.log({ language, resources: !!resources[language] }) + // console.log({ language, resources: resources[language] }) return resources[language] }, diff --git a/tools/helpers/jest.setup.afterEnv.js b/tools/helpers/jest.setup.afterEnv.js index 313e8fa..78d4eb9 100644 --- a/tools/helpers/jest.setup.afterEnv.js +++ b/tools/helpers/jest.setup.afterEnv.js @@ -36,7 +36,7 @@ beforeEach(() => { mockMutationObserver() mockNavigator() HTMLElement.prototype.scrollIntoView = noop - localStorage.setItem('language', 'en') + localStorage.setItem('language', '') jest.useRealTimers() }) diff --git a/yarn.lock b/yarn.lock index bf89aff..86545a5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5338,10 +5338,10 @@ lodash.values@^4.3.0: resolved "https://registry.yarnpkg.com/lodash.values/-/lodash.values-4.3.0.tgz#a3a6c2b0ebecc5c2cba1c17e6e620fe81b53d347" integrity sha1-o6bCsOvsxcLLocF+bmIP6BtT00c= -lodash@^4.17.11, lodash@^4.17.13, lodash@^4.17.14, lodash@^4.17.15: - version "4.17.15" - resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.15.tgz#b447f6670a0455bbfeedd11392eff330ea097548" - integrity sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A== +lodash@4.17.19, lodash@^4.17.11, lodash@^4.17.13, lodash@^4.17.14, lodash@^4.17.15: + version "4.17.19" + resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.19.tgz#e48ddedbe30b3321783c5b4301fbd353bc1e4a4b" + integrity sha512-JNvd8XER9GQX0v2qJgsaN/mzFCNA5BRe/j8JN9d+tWyGLSodKQHKFicdwNYzWwI3wjRnaKPsGj1XkBjx/F96DQ== loglevel@^1.6.6: version "1.6.8"
{status}
{language}
{t('PageOne.hello')}
error: {asyncError.message}
status: {status}
language: {language}