From 82d538c3ef425d3a35546a811b402ce7acf16982 Mon Sep 17 00:00:00 2001 From: Marco Schumacher Date: Thu, 12 Aug 2021 23:56:53 +0200 Subject: [PATCH] feat: Dictionary values can be arrays. --- src/extractICU.ts | 8 ++-- src/flattenDict.ts | 2 +- src/mapPotentialArray.ts | 4 ++ src/react/translator.tsx | 18 +++++--- src/react/types.ts | 5 ++- src/translate.ts | 16 ++++--- src/translator.ts | 4 +- src/types.ts | 28 +++++++----- test/_helpers.ts | 2 + test/react.test.tsx | 43 ++++++++++++++++--- ...translator.test.tsx => translator.test.ts} | 8 ++++ 11 files changed, 103 insertions(+), 35 deletions(-) create mode 100644 src/mapPotentialArray.ts rename test/{translator.test.tsx => translator.test.ts} (87%) diff --git a/src/extractICU.ts b/src/extractICU.ts index 2b412a7..28b2eb6 100644 --- a/src/extractICU.ts +++ b/src/extractICU.ts @@ -19,7 +19,7 @@ type FindBlocks = Text extends `${string}{${infer Right}` //find first { : []; // no {, return empty result /** Find blocks for each tuple entry */ -type TupleFindBlocks = T extends [infer First, ...infer Rest] ? [...FindBlocks, ...TupleFindBlocks] : []; +type TupleFindBlocks = T extends readonly [infer First, ...infer Rest] ? [...FindBlocks, ...TupleFindBlocks] : []; /** Read tail until the currently open block is closed. Return the block content and rest of tail */ type ReadBlock = Tail extends `${infer L1}}${infer R1}` // find first } @@ -38,9 +38,11 @@ type ParseBlock = Block extends `${infer Name},${infer Format},${infer Re : { [K in Trim]: Value }; /** Parse block for each tuple entry */ -type TupleParseBlock = T extends [infer First, ...infer Rest] ? ParseBlock & TupleParseBlock : unknown; +type TupleParseBlock = T extends readonly [infer First, ...infer Rest] ? ParseBlock & TupleParseBlock : unknown; type VariableType = T extends 'number' | 'plural' | 'selectordinal' ? number : T extends 'date' | 'time' ? Date : Value; /** Calculates an object type with all variables and their types in the given ICU format string */ -export type GetICUArgs = T extends string ? TupleParseBlock> : never; +export type GetICUArgs = TupleParseBlock< + T extends readonly string[] ? TupleFindBlocks : FindBlocks +>; diff --git a/src/flattenDict.ts b/src/flattenDict.ts index 4f8e7b8..50ec2ae 100644 --- a/src/flattenDict.ts +++ b/src/flattenDict.ts @@ -6,7 +6,7 @@ export function flattenDict(dict: D, path = ''): FlattenDict for (const [key, value] of Object.entries(dict)) { const newPath = path ? `${path}.${key}` : key; if (value === undefined) continue; - if (value instanceof Object) Object.assign(flat, flattenDict(value, newPath)); + if (value instanceof Object && !(value instanceof Array)) Object.assign(flat, flattenDict(value, newPath)); else flat[newPath] = value; } diff --git a/src/mapPotentialArray.ts b/src/mapPotentialArray.ts new file mode 100644 index 0000000..7d7503f --- /dev/null +++ b/src/mapPotentialArray.ts @@ -0,0 +1,4 @@ +export function mapPotentialArray(value: T | readonly T[], fn: (value: T) => S): S | S[] { + if (value instanceof Array) return value.map(fn); + return fn(value); +} diff --git a/src/react/translator.tsx b/src/react/translator.tsx index d21e074..d227c62 100644 --- a/src/react/translator.tsx +++ b/src/react/translator.tsx @@ -1,4 +1,4 @@ -import React, { createContext, useContext, useMemo } from 'react'; +import React, { createContext, Fragment, useContext, useMemo } from 'react'; import { DictStore } from '../dictStore'; import { format, translate } from '../translate'; import { getTranslator } from '../translator'; @@ -50,7 +50,7 @@ export function createTranslator(options: ReactCreateTranslatorO return format(template, values as any, locale); }; - return Object.assign(t as TranslateKnown, UseTranslatorOptions, string>, { + return Object.assign(t as unknown as TranslateKnown, UseTranslatorOptions, string, readonly string[]>, { unknown: t, format: f, }); @@ -75,8 +75,16 @@ export function createTranslator(options: ReactCreateTranslatorO const fallback = options?.fallback ?? fallbackElement ?? fallbackDefault; const placeholder = options?.placeholder ?? placeholderElement ?? placeholderDefault; const text = translate({ dicts, sourceDict: store.sourceDict, id, values, fallback, placeholder, locale, warn }); - - return <>{text}; + const textArray = text instanceof Array ? text : [text]; + const Component = options?.component ?? Fragment; + + return ( + <> + {textArray.map((line, index) => ( + {line} + ))} + + ); }; const createTranslatorComponent: TranslateUnknown = (id, ...[values, options]) => { @@ -96,7 +104,7 @@ export function createTranslator(options: ReactCreateTranslatorO }; const t: ReactTranslator> = Object.assign( - createTranslatorComponent as TranslateKnown, ReactTranslatorOptions, React.ReactNode>, + createTranslatorComponent as TranslateKnown, ReactTranslatorOptions, React.ReactNode, React.ReactNode>, { unknown: createTranslatorComponent, format: createFormatComponent, diff --git a/src/react/types.ts b/src/react/types.ts index b6f8cb2..ba9a938 100644 --- a/src/react/types.ts +++ b/src/react/types.ts @@ -18,7 +18,7 @@ export type UseTranslatorOptions = { placeholder?: string; }; -export type UseTranslator = (locale?: string) => TranslateKnown & { +export type UseTranslator = (locale?: string) => TranslateKnown & { unknown: TranslateUnknown; format: Format; }; @@ -27,9 +27,10 @@ export type ReactTranslatorOptions = { locale?: string; fallback?: React.ReactNode; placeholder?: React.ReactNode; + component?: React.ElementType; }; -export type ReactTranslator = TranslateKnown & { +export type ReactTranslator = TranslateKnown & { unknown: TranslateUnknown; format: Format; }; diff --git a/src/translate.ts b/src/translate.ts index 75c3be1..1b98688 100644 --- a/src/translate.ts +++ b/src/translate.ts @@ -1,4 +1,5 @@ import { IntlMessageFormat } from 'intl-messageformat'; +import { mapPotentialArray } from './mapPotentialArray'; import { FlatDict } from './types'; const cache = new Map(); @@ -21,13 +22,14 @@ export function translate({ placeholder?: F | ((id: string, sourceTranslation: string) => F); locale: string; warn?: (locale: string, id: string) => void; -}): string | F { +}): string | F | (string | F)[] | F { if (!dicts) { - if (placeholder instanceof Function) { - const sourceTranslation = translate({ dicts: [sourceDict], sourceDict, id, values, locale }); - return placeholder(id, sourceTranslation as string); - } - return placeholder ?? ''; + return mapPotentialArray(translate({ dicts: [sourceDict], sourceDict, id, values, locale }), (sourceTranslation) => { + if (placeholder instanceof Function) { + return placeholder(id, sourceTranslation); + } + return placeholder ?? ''; + }); } if (fallback !== undefined) { @@ -48,7 +50,7 @@ export function translate({ return id; } - return format(template, values, locale); + return mapPotentialArray(template, (template) => format(template, values, locale)); } export function format(template: string, values?: Record, locale?: string): string { diff --git a/src/translator.ts b/src/translator.ts index f942910..6ced546 100644 --- a/src/translator.ts +++ b/src/translator.ts @@ -23,14 +23,14 @@ export const getTranslator = const t: TranslateUnknown = (id, ...[values, options]) => { const fallback = options?.fallback ?? globalFallback; - return translate({ dicts, sourceDict: store.sourceDict, id, values, fallback, locale, warn }) as string; + return translate({ dicts, sourceDict: store.sourceDict, id, values, fallback, locale, warn }); }; const f: Format = (template, ...[values]) => { return format(template, values as any, locale); }; - return Object.assign(t as TranslateKnown, GetTranslatorOptions, string>, { + return Object.assign(t as unknown as TranslateKnown, GetTranslatorOptions, string, readonly string[]>, { unknown: t, format: f, }); diff --git a/src/types.ts b/src/types.ts index e9b8f24..3f9c404 100644 --- a/src/types.ts +++ b/src/types.ts @@ -15,7 +15,9 @@ export type FlatKeys = string & export type DeepValue> = K extends `${infer Head}.${infer Rest}` ? DeepValue> - : D[K] & string; + : D[K] extends string | readonly string[] + ? D[K] + : ''; export type FlattenDict = { [K in FlatKeys]: DeepValue; @@ -24,13 +26,13 @@ export type FlattenDict = { //////////////////////////////////////////////////////////////////////////////// // Public types //////////////////////////////////////////////////////////////////////////////// -export type Dict = { [id: string]: Dict | string }; -export type FlatDict = Record; +export type Dict = { [id: string]: Dict | string | readonly string[] }; +export type FlatDict = Record; export type CreateTranslatorOptions = { sourceDictionary: D; sourceLocale: string; - fallbackLocale?: string | string[]; + fallbackLocale?: string | readonly string[]; dicts?: { [locale: string]: Dict | (() => MaybePromise) } | ((locale: string) => MaybePromise); fallback?: string | ((id: string, sourceTranslation: string) => string); warn?: (locale: string, id: string) => void; @@ -40,16 +42,22 @@ export type CreateTranslatorResult = { getTranslator: GetTranslator; }; -export type Values = Record extends GetICUArgs - ? [values?: Record, options?: Options] +export type Values = Record extends GetICUArgs + ? [values?: Record, options?: Options] + : GetICUArgs extends never + ? [values?: Record, options?: Options] : [values: GetICUArgs, options?: Options]; -export type TranslateKnown = ( +export type TranslateKnown = ( id: K, ...values: Values -) => ReturnValue; +) => D[K] extends readonly string[] ? ArrayReturnValue : ReturnValue; -export type TranslateUnknown = (id: string, values?: Record, options?: Options) => ReturnValue; +export type TranslateUnknown = ( + id: string, + values?: Record, + options?: Options, +) => ReturnValue | readonly ReturnValue[]; export type Format = (template: T, ...values: Values) => ReturnValue; @@ -58,7 +66,7 @@ export type GetTranslatorOptions = { }; export type GetTranslator = (locale: string) => Promise< - TranslateKnown & { + TranslateKnown & { unknown: TranslateUnknown; format: Format; } diff --git a/test/_helpers.ts b/test/_helpers.ts index fa2d2f4..d51d253 100644 --- a/test/_helpers.ts +++ b/test/_helpers.ts @@ -7,6 +7,7 @@ export const wait = async (ticks = 1): Promise => { export const dictEn1 = { key1: 'key1:en', nested: { key2: 'key2:en {value2}' }, + arr: ['one {pOne}', 'two {pTwo}'], } as const; export const dictEn2 = { nested: { @@ -19,6 +20,7 @@ export const dictEn = mergeDicts(dictEn1, dictEn2); export const dictDe1 = { key1: 'key1:de', nested: { key2: 'key2:de {value2}' }, + arr: ['eins {pOne}', 'zwei {pTwo}'], }; export const dictDe2 = { nested: { diff --git a/test/react.test.tsx b/test/react.test.tsx index 4118a2b..c5e386b 100644 --- a/test/react.test.tsx +++ b/test/react.test.tsx @@ -12,16 +12,18 @@ import { } from '../src/react'; import { dictDe, dictEn, dictEs, wait } from './_helpers'; -const test = anyTest as TestInterface>; - -test.beforeEach((t) => { - t.context = createTranslator({ +const createContext = () => + createTranslator({ sourceDictionary: dictEn, sourceLocale: 'en', dicts: { de: dictDe, es: dictEs }, fallback: () => '-', placeholder: (_id, st) => st.replace(/./g, '.'), }); +const test = anyTest as TestInterface>; + +test.beforeEach((t) => { + t.context = createContext(); }); function App({ children }: { children?: React.ReactNode }) { @@ -45,7 +47,8 @@ const forCases = test.serial(`${name} with ${i === 0 ? 'translator' : 'hook'}`, (t) => { function WithHook() { const _t = t.context.useTranslator(); - return <>{_t(id, ...([values, options] as any))}; + const value = _t(id, ...([values, options] as any)); + return <>{value instanceof Array ? value.join('') : value}; } let element; @@ -153,3 +156,33 @@ test.serial('format with hook', async (t) => { fireEvent.click(div); t.is(div.textContent, '1.1.2000'); }); + +test.serial('arr with component', async (t) => { + render({t.context.t('arr', { pOne: 'p1', pTwo: 'p2' }, { component: 'div' })}); + const div = screen.getByTestId('div'); + t.is(div.innerHTML, '
one p1
two p2
'); + + fireEvent.click(div); + await wait(1); + t.is(div.innerHTML, '
eins p1
zwei p2
'); +}); + +test.serial('arr with hook', async (t) => { + function WithHook() { + const _t = t.context.useTranslator(); + const arr = _t('arr', { pOne: 'p1', pTwo: 'p2' }); + return <>{arr[arr.length - 1]}; + } + + render( + + + , + ); + const div = screen.getByTestId('div'); + t.is(div.innerHTML, 'two p2'); + + fireEvent.click(div); + await wait(1); + t.is(div.innerHTML, 'zwei p2'); +}); diff --git a/test/translator.test.tsx b/test/translator.test.ts similarity index 87% rename from test/translator.test.tsx rename to test/translator.test.ts index f7331b1..630c80e 100644 --- a/test/translator.test.tsx +++ b/test/translator.test.ts @@ -62,3 +62,11 @@ test('warn', async (t) => { const en = await getTranslator('en'); t.is(en.unknown('missingKey'), 'missingKey'); }); + +test('array', async (t) => { + const en = await getTranslator('en'); + const de = await getTranslator('de'); + + t.deepEqual(en('arr', { pOne: 'p1', pTwo: 'p2' }), ['one p1', 'two p2']); + t.deepEqual(de('arr', { pOne: 'p1', pTwo: 'p2' }), ['eins p1', 'zwei p2']); +});