Skip to content

Commit

Permalink
feat: Locale inheritance. E.g. "en-US" falls back to "en".
Browse files Browse the repository at this point in the history
feat: Dynamic fallback locale.
  • Loading branch information
schummar committed Oct 19, 2021
1 parent 6772ca2 commit a10d965
Show file tree
Hide file tree
Showing 10 changed files with 63 additions and 19 deletions.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ function createTranslator(options: Options): ReturnValue;
type Options = {
sourceDictionary?: { [id: string]: Dict | string };
sourceLocale: string;
fallbackLocale?: string | string[];
fallbackLocale?: string | readonly string[] | ((locale: string) => string | readonly string[]);
dicts?:
| { [locale: string]: PartialDict<D> | (() => MaybePromise<PartialDict<D>>) }
| ((locale: string) => MaybePromise<PartialDict<D> | null>);
Expand Down
14 changes: 14 additions & 0 deletions src/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,17 @@ export function castArray<T>(x: T | readonly T[] = []): readonly T[] {
if (x instanceof Array) return x;
return [x];
}

export function calcLocales(
locale: string,
fallback?: string | readonly string[] | ((locale: string) => string | readonly string[]),
): readonly string[] {
const requestedLocales = [locale];
if (fallback instanceof Function) {
requestedLocales.push(...castArray(fallback(locale)));
} else if (fallback) {
requestedLocales.push(...castArray(fallback));
}

return requestedLocales;
}
2 changes: 1 addition & 1 deletion src/react/translationContext.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import React, { createContext, useMemo } from 'react';

export const TranslationContext = createContext({
locale: typeof window === 'object' && 'navigator' in window ? window.navigator.language.slice(0, 2) : undefined,
locale: typeof window === 'object' && 'navigator' in window ? window.navigator.language : undefined,
});

export const TranslationContextProvider = ({ locale, children }: { locale?: string; children?: React.ReactNode }): JSX.Element => {
Expand Down
6 changes: 3 additions & 3 deletions src/react/translator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import React, { Fragment, ReactNode, useContext, useMemo } from 'react';
import { TranslationContext } from '.';
import { TranslatorFn } from '..';
import { hash } from '../cache';
import { castArray, toDate } from '../helpers';
import { calcLocales, castArray, toDate } from '../helpers';
import { Store } from '../store';
import { format, translate } from '../translate';
import { createGetTranslator } from '../translator';
Expand Down Expand Up @@ -41,7 +41,7 @@ export function createTranslator<D extends Dict>(options: ReactCreateTranslatorO
const useTranslator: ReactCreateTranslatorResult<FD>['useTranslator'] = (overrideLocale) => {
const contextLocale = useContext(TranslationContext).locale;
const locale = overrideLocale ?? contextLocale ?? sourceLocale;
const dicts = useStore(store, locale, ...castArray(fallbackLocale));
const dicts = useStore(store, locale, ...calcLocales(locale, fallbackLocale));
const [sourceDict] = useStore(store, sourceLocale);

return useMemo(() => {
Expand Down Expand Up @@ -103,7 +103,7 @@ export function createTranslator<D extends Dict>(options: ReactCreateTranslatorO
}) {
const contextLocale = useContext(TranslationContext).locale;
const locale = options?.locale ?? contextLocale ?? sourceLocale;
const dicts = useStore(store, locale, ...castArray(fallbackLocale));
const dicts = useStore(store, locale, ...calcLocales(locale, fallbackLocale));
const [sourceDict] = useStore(store, sourceLocale);

const fallback = options?.fallback ?? defaultFallback;
Expand Down
14 changes: 8 additions & 6 deletions src/store.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { match } from '@formatjs/intl-localematcher';
import { Cache } from './cache';
import { flattenDict } from './flattenDict';
import { arrEquals } from './helpers';
Expand All @@ -14,15 +15,16 @@ export class Store<D extends Dict = any> {
let entry = this.dicts.get(locale);
if (entry !== undefined) return entry;

let dict;
if (locale === this.options.sourceLocale && this.options.sourceDictionary) {
let dict = null;
if (match([locale], [this.options.sourceLocale], '') && this.options.sourceDictionary) {
dict = this.options.sourceDictionary;
} else if (this.options.dicts instanceof Function) {
dict = this.options.dicts(locale);
} else {
const get = this.options.dicts?.[locale] ?? null;
if (get instanceof Function) dict = get();
else dict = get;
} else if (this.options.dicts) {
const availableLocales = Object.keys(this.options.dicts);
const matching = match([locale], availableLocales, locale);
dict = this.options.dicts[matching] ?? null;
if (dict instanceof Function) dict = dict();
}

if (dict instanceof Promise) {
Expand Down
2 changes: 1 addition & 1 deletion src/translate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ export function translate<F = never>({
dicts = dicts.slice(0, 1);
}

const dict = dicts?.find((dict) => dict instanceof Promise || id in dict);
const dict = dicts.find((dict) => dict instanceof Promise || id in dict);

if (dict instanceof Promise) {
return mapPotentialArray(
Expand Down
4 changes: 2 additions & 2 deletions src/translator.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { TranslatorFn } from '.';
import { castArray, toDate } from './helpers';
import { calcLocales, toDate } from './helpers';
import { Store } from './store';
import { format, translate } from './translate';
import { CreateTranslatorOptions, CreateTranslatorResult, Dict, FlattenDict, Translator } from './types';
Expand All @@ -23,7 +23,7 @@ export const createGetTranslator =
async (locale: string) => {
type FD = FlattenDict<D>;

const dicts = await store.loadAll(locale, ...castArray(fallbackLocale));
const dicts = await store.loadAll(locale, ...calcLocales(locale, fallbackLocale));
const sourceDict = await store.load(sourceLocale);

const t: TranslatorFn<FD> = (id, ...[values, options]) => {
Expand Down
2 changes: 1 addition & 1 deletion src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ export interface CreateTranslatorOptions<D extends Dict> {
/** The source dictionary's locale */
sourceLocale: string;
/** Locale(s) to fall back to if a string is not available in the active locale */
fallbackLocale?: string | readonly string[];
fallbackLocale?: string | readonly string[] | ((locale: string) => string | readonly string[]);
/** Dictionaries. Either a record with locales as keys or a function that takes a locale and returns a promise of a dictionary
* @param locale the active locale
*/
Expand Down
27 changes: 23 additions & 4 deletions test/react.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,18 @@ test.beforeEach((t) => {
t.context = createContext();
});

function App({ id, locales = ['en', 'de', 'es'], children }: { id: string; locales?: string[]; children?: React.ReactNode }) {
const [locale, setLocale] = useState(locales[0]);
function App({
id,
locales = ['en', 'de', 'es'],
initialLocale = locales[0],
children,
}: {
id: string;
locales?: string[];
initialLocale?: string;
children?: React.ReactNode;
}) {
const [locale, setLocale] = useState(initialLocale);
const toggleLocale = () => setLocale((l) => locales[(l ? locales.indexOf(l) + 1 : 0) % locales.length]);

return (
Expand Down Expand Up @@ -52,7 +62,7 @@ const forCases = (
name: string,
renderFn: (t: HookTranslator<D>) => ReactNode,
assertionFn: (t: ExecutionContext, div: HTMLElement) => MaybePromise<void>,
{ locales }: { locales?: string[] } = {},
{ locales, initialLocale }: { locales?: string[]; initialLocale?: string } = {},
) => {
for (const i of [0, 1]) {
test(`${name} with ${i === 0 ? 'translator' : 'hook'}`, (t) => {
Expand All @@ -64,7 +74,7 @@ const forCases = (
}

render(
<App id={t.title} locales={locales}>
<App id={t.title} locales={locales} initialLocale={initialLocale}>
{element}
</App>,
);
Expand Down Expand Up @@ -246,6 +256,15 @@ forCases(
},
);

forCases(
'match locales',
(t) => t('key1'),
(t, div) => {
t.is(div.textContent, 'key1:en');
},
{ initialLocale: 'en-US' },
);

test('arr with component', async (t) => {
render(<App id={t.title}>{t.context.t('arr', { pOne: 'p1', pTwo: 'p2' }, { component: 'div' })}</App>);
const div = screen.getByTestId(t.title);
Expand Down
9 changes: 9 additions & 0 deletions test/translator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,3 +173,12 @@ test('cache', async (t) => {
cache.get(Mock, { n: 1 });
t.is(count, 3);
});

test('match locales', async (t) => {
const { getTranslator } = createTranslator({
sourceLocale: 'en',
sourceDictionary: dictEn,
});
const _t = await getTranslator('en-US');
t.is(_t('key1'), 'key1:en');
});

0 comments on commit a10d965

Please sign in to comment.