From 1023218dd0ec0e102db27a8e6de2478f0af8f239 Mon Sep 17 00:00:00 2001 From: eolme Date: Sun, 1 May 2022 19:35:38 +0300 Subject: [PATCH] Release --- README.md | 4 +- jest.config.js | 2 +- package.json | 4 +- src/compat.ts | 12 +++ src/react.ts | 37 ++++---- src/types.ts | 12 +-- tests/{all.spec.ts => change.spec.ts} | 93 ++----------------- tests/components.spec.ts | 68 ++++++++++++++ tests/hoc.spec.ts | 54 ++++++++++++ tests/provider.spec.ts | 108 +++++++++++++++++++++++ tests/shared.ts | 36 ++++++++ tests/{suspense/index.ts => suspense.ts} | 0 tests/templates.spec.ts | 36 ++++++++ 13 files changed, 356 insertions(+), 110 deletions(-) create mode 100644 src/compat.ts rename tests/{all.spec.ts => change.spec.ts} (62%) create mode 100644 tests/components.spec.ts create mode 100644 tests/hoc.spec.ts create mode 100644 tests/provider.spec.ts create mode 100644 tests/shared.ts rename tests/{suspense/index.ts => suspense.ts} (100%) create mode 100644 tests/templates.spec.ts diff --git a/README.md b/README.md index 3149c3d..cebfd50 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# i18nano [![GitHub license](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/eolme/next-sw/blob/master/LICENSE) [![BundlePhobia](https://img.shields.io/bundlephobia/minzip/i18nano)](https://bundlephobia.com/package/i18nano) [![BundlePhobia](https://img.shields.io/bundlephobia/min/i18nano)](https://bundlephobia.com/package/i18nano) +# i18nano [![GitHub license](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/eolme/i18nano/blob/master/LICENSE) [![BundlePhobia](https://img.shields.io/bundlephobia/minzip/i18nano)](https://bundlephobia.com/package/i18nano) [![BundlePhobia](https://img.shields.io/bundlephobia/min/i18nano)](https://bundlephobia.com/package/i18nano) [![Coverage](https://img.shields.io/badge/coverage-100%25-brightgreen)](https://github.com/eolme/i18nano/blob/master/tests) > Internationalization for the react is done simply. @@ -84,7 +84,7 @@ export const LanguageChange = () => { ## Concurrent features -If you use react 18 it is recommended to use `unstable_transition`. +If you use react 18 it is recommended to use `transition`. Then when you switch languages, the last downloaded translation will be displayed instead of the loader. ## Split diff --git a/jest.config.js b/jest.config.js index 26d3381..bc09c2e 100644 --- a/jest.config.js +++ b/jest.config.js @@ -15,7 +15,7 @@ const config = { resolver: 'jest-ts-webcompat-resolver', testEnvironment: 'node', testMatch: [ - '**/tests/*.ts' + '**/tests/*.spec.ts' ], collectCoverage: true, collectCoverageFrom: [ diff --git a/package.json b/package.json index 82a837b..2a76090 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "i18nano", - "version": "1.2.0", + "version": "2.0.0", "source": "./src/index.ts", "main": "./lib/index.cjs", "module": "./lib/index.js", @@ -51,6 +51,8 @@ "eslint-plugin-jest-formatting": "^3.1.0", "jest": "^28.0.3", "jest-esbuild": "^0.2.6", + "jest-mock": "^28.0.2", + "jest-resolve": "^28.0.3", "jest-ts-webcompat-resolver": "^1.0.0", "nanobundle": "^0.0.27", "react": "^18.1.0", diff --git a/src/compat.ts b/src/compat.ts new file mode 100644 index 0000000..db85151 --- /dev/null +++ b/src/compat.ts @@ -0,0 +1,12 @@ +import type { PropsWithChildren, ComponentType as _ComponentType, FC as _FC } from 'react'; + +type PropsWithoutChildren

= P extends any ? ('children' extends keyof P ? Pick> : P) : P; + +// eslint-disable-next-line @typescript-eslint/ban-types +export type FC

= _FC>; + +// eslint-disable-next-line @typescript-eslint/ban-types +export type FCC

= _FC>; + +// TODO: maybe incompatible +export type { _ComponentType as ComponentType }; diff --git a/src/react.ts b/src/react.ts index df18080..c962eb3 100644 --- a/src/react.ts +++ b/src/react.ts @@ -1,4 +1,4 @@ -import type { ComponentType, FC } from 'react'; +import type { ComponentType, FC, FCC } from './compat.js'; import type { TranslationChange, TranslationChangeProps, @@ -60,11 +60,11 @@ const lookupValue = (path: string, values: TranslationValues): string => { return get(values, path, GET_OPTIONS); }; -const lookup = (path: string, values: TranslationValues, lang: TranslationValues) => { +const lookup = (path: string, values: TranslationValues | null, lang: TranslationValues) => { let resolved = lookupValue(path, lang); - for (const key in values) { - resolved = resolved.replace(`{{${key}}}`, lookupValue(key, values)); + if (values !== null) { + resolved = resolved.replace(/{{(.+?)}}/g, (_, key) => lookupValue(key, values)); } return resolved; @@ -78,16 +78,16 @@ const TranslationChangeContext = React.createContext({ preload: noop }); -export const TranslationProvider: FC = ({ - language = 'en', +export const TranslationProvider: FCC = ({ + language, preloadLanguage = true, fallback = language, preloadFallback = false, - translations = {}, + translations, - unstable_transition = false, + transition = false, children }) => { @@ -106,7 +106,7 @@ export const TranslationProvider: FC = ({ suspendPreload(translations[next], keyed(next)); }; - if (preloadLanguage && language !== lang) { + if (preloadLanguage && language === lang) { preload(lang); } @@ -114,16 +114,16 @@ export const TranslationProvider: FC = ({ preload(fallback); } - const transition = unstable_transition ? startTransition : invoke; + const withTransition = transition ? startTransition : invoke; const change = (next: string) => { setCurrent(next); - transition(() => { + withTransition(() => { setLanguage(next); }); }; - const t = React.useCallback((path: string, values: TranslationValues = {}) => { + const t = React.useCallback((path, values = null) => { let result = EMPTY; if (lang in translations) { @@ -182,7 +182,7 @@ export const useTranslationChange = () => { * @see https://reactjs.org/docs/concurrent-mode-suspense.html */ export const withTranslation =

(Component: ComponentType

) => { - const WithTranslation: FC

= (props) => { + const WithTranslation: FCC

= (props) => { const t = useTranslation(); return React.createElement(Component, Object.assign({}, props, { t })); @@ -192,7 +192,7 @@ export const withTranslation =

(Component: ComponentType

(Component: ComponentType

) => { - const WithTranslationChange: FC

= (props) => { + const WithTranslationChange: FCC

= (props) => { const translation = useTranslationChange(); return React.createElement(Component, Object.assign({}, props, { translation })); @@ -210,7 +210,10 @@ export const withTranslationChange =

(Component: ComponentType

= ({ path, values }) => { +export const TranslationRender: FC = ({ + path, + values = null +}) => { const t = useTranslation(); return t(path, values); @@ -225,10 +228,10 @@ export const TranslationRender: FC = ({ path, values }) => { * * @see {@link TranslationRender} */ -export const Translation: FC = ({ +export const Translation: FCC = ({ children = null, path, - values + values = null }) => { return React.createElement( React.Suspense, diff --git a/src/types.ts b/src/types.ts index 9364d71..897ab3b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -2,7 +2,8 @@ export type TranslationValues = { [key: string | number]: string | TranslationValues; } | Array; -export type TranslationFunction = (path: string, values?: TranslationValues) => string; +export type TranslationFunction = (path: string, values?: TranslationValues | null | undefined) => string; + export type TranslationFunctionProps = { t: TranslationFunction; }; @@ -13,23 +14,24 @@ export type TranslationChange = Readonly<{ change: (next: string) => void; preload: (next: string) => void; }>; + export type TranslationChangeProps = { translation: TranslationChange; }; export type TranslationProps = { path: string; - values?: TranslationValues; + values?: TranslationValues | null | undefined; }; export type TranslationProviderProps = { - language?: string; + language: string; preloadLanguage?: boolean; fallback?: string; preloadFallback?: boolean; - translations?: Record Promise>; + translations: Record Promise>; - unstable_transition?: boolean; + transition?: boolean; }; diff --git a/tests/all.spec.ts b/tests/change.spec.ts similarity index 62% rename from tests/all.spec.ts rename to tests/change.spec.ts index ff1f3a9..4c5bd9b 100644 --- a/tests/all.spec.ts +++ b/tests/change.spec.ts @@ -1,92 +1,17 @@ import React from 'react'; import renderer from 'react-test-renderer'; -import { waitForSuspense } from './suspense'; -import * as Module from '../src'; +import { waitForSuspense } from './suspense.js'; -const TRANSLATIONS = { - ru: async () => ({ - ru: 'ru' - }), - it: async () => ({ - it: 'it' - }) -}; - -const TRANSLATIONS_KEYS = Object.keys(TRANSLATIONS); - -const SUSPENSE = 'suspense'; - -const NOOP = () => { - // Noop -}; - -describe('all spec', () => { - it('renders correctly', () => { - expect.assertions(1); - - const component = renderer.create( - React.createElement(Module.TranslationProvider) - ); - - expect(component.toTree()).toMatchObject({ - nodeType: 'component', - type: Module.TranslationProvider, - props: {}, - instance: null, - rendered: null - }); - }); - - it('renders correctly children', () => { - expect.assertions(1); - - const component = renderer.create( - React.createElement( - Module.TranslationProvider, - null, - React.createElement('div', { - 'data-children': true - }) - ) - ); - - expect(component.toTree()!.rendered).toMatchObject({ - nodeType: 'host', - type: 'div', - props: { - 'data-children': true - }, - instance: null, - rendered: [] - }); - }); - - it.each([ - ['language', TRANSLATIONS_KEYS[0], 'lang', TRANSLATIONS_KEYS[0]], - ['translations', TRANSLATIONS, 'all', TRANSLATIONS_KEYS] - ])('handles prop "%s" correctly', (prop, value, by, match) => { - expect.assertions(1); - - const component = renderer.create( - React.createElement( - Module.TranslationProvider, - { - [prop]: value - }, - - // @ts-expect-error DefinitelyTyped issue - React.createElement(() => { - const translation: Record = Module.useTranslationChange(); - - return translation[by]; - }) - ) - ); - - expect(component.toJSON()).toStrictEqual(match); - }); +import { + Module, + NOOP, + SUSPENSE, + TRANSLATIONS, + TRANSLATIONS_KEYS +} from './shared.js'; +describe('change', () => { it.each([ [TRANSLATIONS_KEYS[0], TRANSLATIONS_KEYS[0]], [TRANSLATIONS_KEYS[0], TRANSLATIONS_KEYS[1]], diff --git a/tests/components.spec.ts b/tests/components.spec.ts new file mode 100644 index 0000000..c0eb605 --- /dev/null +++ b/tests/components.spec.ts @@ -0,0 +1,68 @@ +import React from 'react'; +import renderer from 'react-test-renderer'; + +import { waitForSuspense } from './suspense.js'; + +import { + DEFAULT_PROPS, + Module, + NOOP, + SUSPENSE +} from './shared.js'; + +describe('components', () => { + it('component TranslationRender', async () => { + expect.assertions(2); + + const component = renderer.create( + React.createElement( + Module.TranslationProvider, + DEFAULT_PROPS, + React.createElement( + React.Suspense, + { + fallback: SUSPENSE + }, + React.createElement( + Module.TranslationRender, + { + path: DEFAULT_PROPS.language + } + ) + ) + ) + ); + + expect(component.toJSON()).toBe(SUSPENSE); + + await renderer.act(NOOP); + await waitForSuspense(NOOP); + + expect(component.toJSON()).toBe(DEFAULT_PROPS.language); + }); + + it('component Translation', async () => { + expect.assertions(2); + + const component = renderer.create( + React.createElement( + Module.TranslationProvider, + DEFAULT_PROPS, + React.createElement( + Module.Translation, + { + path: DEFAULT_PROPS.language + }, + SUSPENSE + ) + ) + ); + + expect(component.toJSON()).toBe(SUSPENSE); + + await renderer.act(NOOP); + await waitForSuspense(NOOP); + + expect(component.toJSON()).toBe(DEFAULT_PROPS.language); + }); +}); diff --git a/tests/hoc.spec.ts b/tests/hoc.spec.ts new file mode 100644 index 0000000..c326c1d --- /dev/null +++ b/tests/hoc.spec.ts @@ -0,0 +1,54 @@ +import React from 'react'; +import renderer from 'react-test-renderer'; + +import { + DEFAULT_PROPS, + Module, + NOOP +} from './shared.js'; + +describe('hoc', () => { + it('hoc withTranslation', async () => { + expect.assertions(1); + + renderer.create( + React.createElement( + Module.TranslationProvider, + DEFAULT_PROPS, + React.createElement( + Module.withTranslation(({ t }) => { + expect(t).toBeInstanceOf(Function); + + return null; + }) + ) + ) + ); + + await renderer.act(NOOP); + }); + + it('hoc withTranslationChange', async () => { + expect.assertions(5); + + renderer.create( + React.createElement( + Module.TranslationProvider, + DEFAULT_PROPS, + React.createElement( + Module.withTranslationChange(({ translation }) => { + expect(translation).toBeInstanceOf(Object); + expect(translation.all).toStrictEqual(Object.keys(DEFAULT_PROPS.translations)); + expect(translation.lang).toBe(DEFAULT_PROPS.language); + expect(translation.change).toBeInstanceOf(Function); + expect(translation.preload).toBeInstanceOf(Function); + + return null; + }) + ) + ) + ); + + await renderer.act(NOOP); + }); +}); diff --git a/tests/provider.spec.ts b/tests/provider.spec.ts new file mode 100644 index 0000000..3e55031 --- /dev/null +++ b/tests/provider.spec.ts @@ -0,0 +1,108 @@ +import React from 'react'; +import renderer from 'react-test-renderer'; + +import { fn } from 'jest-mock'; + +import { waitForSuspense } from './suspense.js'; + +import { + DEFAULT_PROPS, + Module, + NOOP, + SUSPENSE, + TRANSLATIONS, + TRANSLATIONS_KEYS +} from './shared.js'; + +describe('provider', () => { + it('renders correctly', () => { + expect.assertions(1); + + const component = renderer.create( + React.createElement( + Module.TranslationProvider, + DEFAULT_PROPS, + React.createElement('div', { + 'data-children': true + }) + ) + ); + + expect(component.toTree()!.rendered).toMatchObject({ + nodeType: 'host', + type: 'div', + props: { + 'data-children': true + }, + instance: null, + rendered: [] + }); + }); + + it.each([ + ['language', 0], + ['fallback', 1] + ])('handles prop "%s" correctly', async (prop, key) => { + expect.assertions(2); + + const component = renderer.create( + React.createElement( + Module.TranslationProvider, + { + language: TRANSLATIONS_KEYS[0], + preloadLanguage: false, + fallback: TRANSLATIONS_KEYS[1], + preloadFallback: false, + translations: TRANSLATIONS + }, + React.createElement( + Module.Translation, + { + path: TRANSLATIONS_KEYS[key] + }, + SUSPENSE + ) + ) + ); + + expect(component.toJSON()).toBe(SUSPENSE); + + await renderer.act(NOOP); + await waitForSuspense(NOOP); + + expect(component.toJSON()).toBe(TRANSLATIONS_KEYS[key]); + }); + + it.each([ + ['preloadLanguage', false, 0, 0], + ['preloadLanguage', true, 1, 0], + ['preloadFallback', false, 0, 0], + ['preloadFallback', true, 0, 1] + ])('handles prop "%s" correctly', async (prop, value, lc, fc) => { + expect.assertions(2); + + const translations = { + language: fn(async () => ({})), + fallback: fn(async () => ({})) + }; + + renderer.create( + React.createElement( + Module.TranslationProvider, + { + language: 'language', + preloadLanguage: false, + fallback: 'fallback', + preloadFallback: false, + translations, + [prop]: value + } + ) + ); + + await renderer.act(NOOP); + + expect(translations.language).toHaveBeenCalledTimes(lc); + expect(translations.fallback).toHaveBeenCalledTimes(fc); + }); +}); diff --git a/tests/shared.ts b/tests/shared.ts new file mode 100644 index 0000000..590747c --- /dev/null +++ b/tests/shared.ts @@ -0,0 +1,36 @@ +export const TRANSLATIONS = { + ru: async () => ({ + ru: 'ru', + template: '{{value}} {{nested.value}} {{array.0}} {{array.1.value}}' + }), + it: async () => ({ + it: 'it', + template: '{{value}} {{nested.value}} {{array.0}} {{array.1.value}}' + }) +}; + +export const TRANSLATIONS_KEYS = Object.keys(TRANSLATIONS); + +export const VALUES = { + value: '0', + nested: { + value: '1' + }, + array: [ + '2', + { value: '3' } + ] +}; + +export const DEFAULT_PROPS = { + language: TRANSLATIONS_KEYS[0], + translations: TRANSLATIONS +}; + +export const SUSPENSE = 'suspense'; + +export const NOOP = () => { + // Noop +}; + +export * as Module from '../src'; diff --git a/tests/suspense/index.ts b/tests/suspense.ts similarity index 100% rename from tests/suspense/index.ts rename to tests/suspense.ts diff --git a/tests/templates.spec.ts b/tests/templates.spec.ts new file mode 100644 index 0000000..0ad058c --- /dev/null +++ b/tests/templates.spec.ts @@ -0,0 +1,36 @@ +import React from 'react'; +import renderer from 'react-test-renderer'; + +import { waitForSuspense } from './suspense.js'; + +import { + DEFAULT_PROPS, + Module, + NOOP, + VALUES +} from './shared.js'; + +describe('templates', () => { + it('should interpolate mustache-like templates', async () => { + expect.assertions(1); + + const component = renderer.create( + React.createElement( + Module.TranslationProvider, + DEFAULT_PROPS, + React.createElement( + Module.Translation, + { + path: 'template', + values: VALUES + } + ) + ) + ); + + await renderer.act(NOOP); + await waitForSuspense(NOOP); + + expect(component.toJSON()).toBe('0 1 2 3'); + }); +});