Skip to content

Commit

Permalink
feat: Dictionary values can be arrays.
Browse files Browse the repository at this point in the history
  • Loading branch information
schummar committed Aug 12, 2021
1 parent 4a9efaf commit 82d538c
Show file tree
Hide file tree
Showing 11 changed files with 103 additions and 35 deletions.
8 changes: 5 additions & 3 deletions src/extractICU.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ type FindBlocks<Text> = Text extends `${string}{${infer Right}` //find first {
: []; // no {, return empty result

/** Find blocks for each tuple entry */
type TupleFindBlocks<T> = T extends [infer First, ...infer Rest] ? [...FindBlocks<First>, ...TupleFindBlocks<Rest>] : [];
type TupleFindBlocks<T> = T extends readonly [infer First, ...infer Rest] ? [...FindBlocks<First>, ...TupleFindBlocks<Rest>] : [];

/** Read tail until the currently open block is closed. Return the block content and rest of tail */
type ReadBlock<Block extends string, Tail extends string, Depth extends string> = Tail extends `${infer L1}}${infer R1}` // find first }
Expand All @@ -38,9 +38,11 @@ type ParseBlock<Block> = Block extends `${infer Name},${infer Format},${infer Re
: { [K in Trim<Block>]: Value };

/** Parse block for each tuple entry */
type TupleParseBlock<T> = T extends [infer First, ...infer Rest] ? ParseBlock<First> & TupleParseBlock<Rest> : unknown;
type TupleParseBlock<T> = T extends readonly [infer First, ...infer Rest] ? ParseBlock<First> & TupleParseBlock<Rest> : unknown;

type VariableType<T extends string> = 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> = T extends string ? TupleParseBlock<FindBlocks<T>> : never;
export type GetICUArgs<T extends string | readonly string[]> = TupleParseBlock<
T extends readonly string[] ? TupleFindBlocks<T> : FindBlocks<T>
>;
2 changes: 1 addition & 1 deletion src/flattenDict.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ export function flattenDict<D extends Dict>(dict: D, path = ''): FlattenDict<D>
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;
}

Expand Down
4 changes: 4 additions & 0 deletions src/mapPotentialArray.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export function mapPotentialArray<T, S>(value: T | readonly T[], fn: (value: T) => S): S | S[] {
if (value instanceof Array) return value.map(fn);
return fn(value);
}
18 changes: 13 additions & 5 deletions src/react/translator.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -50,7 +50,7 @@ export function createTranslator<D extends Dict>(options: ReactCreateTranslatorO
return format(template, values as any, locale);
};

return Object.assign(t as TranslateKnown<FlattenDict<D>, UseTranslatorOptions, string>, {
return Object.assign(t as unknown as TranslateKnown<FlattenDict<D>, UseTranslatorOptions, string, readonly string[]>, {
unknown: t,
format: f,
});
Expand All @@ -75,8 +75,16 @@ export function createTranslator<D extends Dict>(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) => (
<Component key={index}>{line}</Component>
))}
</>
);
};

const createTranslatorComponent: TranslateUnknown<ReactTranslatorOptions, React.ReactNode> = (id, ...[values, options]) => {
Expand All @@ -96,7 +104,7 @@ export function createTranslator<D extends Dict>(options: ReactCreateTranslatorO
};

const t: ReactTranslator<FlattenDict<D>> = Object.assign(
createTranslatorComponent as TranslateKnown<FlattenDict<D>, ReactTranslatorOptions, React.ReactNode>,
createTranslatorComponent as TranslateKnown<FlattenDict<D>, ReactTranslatorOptions, React.ReactNode, React.ReactNode>,
{
unknown: createTranslatorComponent,
format: createFormatComponent,
Expand Down
5 changes: 3 additions & 2 deletions src/react/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export type UseTranslatorOptions = {
placeholder?: string;
};

export type UseTranslator<D extends FlatDict> = (locale?: string) => TranslateKnown<D, UseTranslatorOptions, string> & {
export type UseTranslator<D extends FlatDict> = (locale?: string) => TranslateKnown<D, UseTranslatorOptions, string, readonly string[]> & {
unknown: TranslateUnknown<UseTranslatorOptions, string>;
format: Format<string>;
};
Expand All @@ -27,9 +27,10 @@ export type ReactTranslatorOptions = {
locale?: string;
fallback?: React.ReactNode;
placeholder?: React.ReactNode;
component?: React.ElementType;
};

export type ReactTranslator<D extends FlatDict> = TranslateKnown<D, ReactTranslatorOptions, React.ReactNode> & {
export type ReactTranslator<D extends FlatDict> = TranslateKnown<D, ReactTranslatorOptions, React.ReactNode, React.ReactNode> & {
unknown: TranslateUnknown<ReactTranslatorOptions, React.ReactNode>;
format: Format<React.ReactNode>;
};
16 changes: 9 additions & 7 deletions src/translate.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { IntlMessageFormat } from 'intl-messageformat';
import { mapPotentialArray } from './mapPotentialArray';
import { FlatDict } from './types';

const cache = new Map<string, IntlMessageFormat>();
Expand All @@ -21,13 +22,14 @@ export function translate<F = never>({
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<string>({ dicts: [sourceDict], sourceDict, id, values, locale });
return placeholder(id, sourceTranslation as string);
}
return placeholder ?? '';
return mapPotentialArray(translate<string>({ dicts: [sourceDict], sourceDict, id, values, locale }), (sourceTranslation) => {
if (placeholder instanceof Function) {
return placeholder(id, sourceTranslation);
}
return placeholder ?? '';
});
}

if (fallback !== undefined) {
Expand All @@ -48,7 +50,7 @@ export function translate<F = never>({
return id;
}

return format(template, values, locale);
return mapPotentialArray(template, (template) => format(template, values, locale));
}

export function format(template: string, values?: Record<string, unknown>, locale?: string): string {
Expand Down
4 changes: 2 additions & 2 deletions src/translator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,14 @@ export const getTranslator =

const t: TranslateUnknown<GetTranslatorOptions, string> = (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<string> = (template, ...[values]) => {
return format(template, values as any, locale);
};

return Object.assign(t as TranslateKnown<FlattenDict<D>, GetTranslatorOptions, string>, {
return Object.assign(t as unknown as TranslateKnown<FlattenDict<D>, GetTranslatorOptions, string, readonly string[]>, {
unknown: t,
format: f,
});
Expand Down
28 changes: 18 additions & 10 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@ export type FlatKeys<D extends Dict> = string &

export type DeepValue<D extends Dict, K extends FlatKeys<D>> = K extends `${infer Head}.${infer Rest}`
? DeepValue<D[Head] & Dict, Rest & FlatKeys<D[Head] & Dict>>
: D[K] & string;
: D[K] extends string | readonly string[]
? D[K]
: '';

export type FlattenDict<D extends Dict> = {
[K in FlatKeys<D>]: DeepValue<D, K>;
Expand All @@ -24,13 +26,13 @@ export type FlattenDict<D extends Dict> = {
////////////////////////////////////////////////////////////////////////////////
// Public types
////////////////////////////////////////////////////////////////////////////////
export type Dict = { [id: string]: Dict | string };
export type FlatDict = Record<string, string>;
export type Dict = { [id: string]: Dict | string | readonly string[] };
export type FlatDict = Record<string, string | readonly string[]>;

export type CreateTranslatorOptions<D extends Dict> = {
sourceDictionary: D;
sourceLocale: string;
fallbackLocale?: string | string[];
fallbackLocale?: string | readonly string[];
dicts?: { [locale: string]: Dict | (() => MaybePromise<Dict>) } | ((locale: string) => MaybePromise<Dict | null>);
fallback?: string | ((id: string, sourceTranslation: string) => string);
warn?: (locale: string, id: string) => void;
Expand All @@ -40,16 +42,22 @@ export type CreateTranslatorResult<D extends FlatDict> = {
getTranslator: GetTranslator<D>;
};

export type Values<T, Options = never> = Record<string, never> extends GetICUArgs<T>
? [values?: Record<string, never>, options?: Options]
export type Values<T extends string | readonly string[], Options = never> = Record<string, never> extends GetICUArgs<T>
? [values?: Record<string, unknown>, options?: Options]
: GetICUArgs<T> extends never
? [values?: Record<string, unknown>, options?: Options]
: [values: GetICUArgs<T>, options?: Options];

export type TranslateKnown<D extends FlatDict, Options, ReturnValue> = <K extends keyof D>(
export type TranslateKnown<D extends FlatDict, Options, ReturnValue, ArrayReturnValue> = <K extends keyof D>(
id: K,
...values: Values<D[K], Options>
) => ReturnValue;
) => D[K] extends readonly string[] ? ArrayReturnValue : ReturnValue;

export type TranslateUnknown<Options, ReturnValue> = (id: string, values?: Record<string, unknown>, options?: Options) => ReturnValue;
export type TranslateUnknown<Options, ReturnValue> = (
id: string,
values?: Record<string, unknown>,
options?: Options,
) => ReturnValue | readonly ReturnValue[];

export type Format<ReturnValue> = <T extends string>(template: T, ...values: Values<T>) => ReturnValue;

Expand All @@ -58,7 +66,7 @@ export type GetTranslatorOptions = {
};

export type GetTranslator<D extends FlatDict> = (locale: string) => Promise<
TranslateKnown<D, GetTranslatorOptions, string> & {
TranslateKnown<D, GetTranslatorOptions, string, readonly string[]> & {
unknown: TranslateUnknown<GetTranslatorOptions, string>;
format: Format<string>;
}
Expand Down
2 changes: 2 additions & 0 deletions test/_helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export const wait = async (ticks = 1): Promise<void> => {
export const dictEn1 = {
key1: 'key1:en',
nested: { key2: 'key2:en {value2}' },
arr: ['one {pOne}', 'two {pTwo}'],
} as const;
export const dictEn2 = {
nested: {
Expand All @@ -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: {
Expand Down
43 changes: 38 additions & 5 deletions test/react.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,16 +12,18 @@ import {
} from '../src/react';
import { dictDe, dictEn, dictEs, wait } from './_helpers';

const test = anyTest as TestInterface<ReturnType<typeof createTranslator>>;

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<ReturnType<typeof createContext>>;

test.beforeEach((t) => {
t.context = createContext();
});

function App({ children }: { children?: React.ReactNode }) {
Expand All @@ -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;
Expand Down Expand Up @@ -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(<App>{t.context.t('arr', { pOne: 'p1', pTwo: 'p2' }, { component: 'div' })}</App>);
const div = screen.getByTestId('div');
t.is(div.innerHTML, '<div>one p1</div><div>two p2</div>');

fireEvent.click(div);
await wait(1);
t.is(div.innerHTML, '<div>eins p1</div><div>zwei p2</div>');
});

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(
<App>
<WithHook />
</App>,
);
const div = screen.getByTestId('div');
t.is(div.innerHTML, 'two p2');

fireEvent.click(div);
await wait(1);
t.is(div.innerHTML, 'zwei p2');
});
8 changes: 8 additions & 0 deletions test/translator.test.tsx → test/translator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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']);
});

0 comments on commit 82d538c

Please sign in to comment.