diff --git a/.changeset/polite-phones-pull.md b/.changeset/polite-phones-pull.md new file mode 100644 index 000000000..81aeb503b --- /dev/null +++ b/.changeset/polite-phones-pull.md @@ -0,0 +1,6 @@ +--- +"@solid-primitives/i18n": minor +--- + +Make i18n translator resolver fully generic. +Proper types for available scopes and support for nested scopes such as for example `app.test`. diff --git a/packages/i18n/src/index.ts b/packages/i18n/src/index.ts index af1006edc..f9164909e 100644 --- a/packages/i18n/src/index.ts +++ b/packages/i18n/src/index.ts @@ -1,4 +1,4 @@ -export type BaseRecordDict = { readonly [K: string | number]: unknown }; +export type BaseRecordDict = Readonly>; export type BaseArrayDict = readonly unknown[]; export type BaseDict = BaseRecordDict | BaseArrayDict; @@ -25,7 +25,7 @@ type UnionToIntersection = (U extends any ? (k: U) => void : never) extends ( * Flatten a nested dictionary into a flat dictionary. * * @example - * ```ts + * ```typescript * type Dict = { * a: { * foo: string; @@ -41,26 +41,27 @@ type UnionToIntersection = (U extends any ? (k: U) => void : never) extends ( * b: { bar: number } * }, * "a.foo": string; - * "a.b": { bar: number} , + * "a.b": { bar: number } * "a.b.bar": number; * } * ``` */ -export type Flatten = number extends T +export type Flatten = number extends Dict ? /* catch any */ BaseRecordDict - : T extends (infer V)[] + : Dict extends (infer V)[] ? /* array */ { readonly [K in JoinPath]?: V } & (V extends BaseDict - ? Partial>> - : {}) + ? Partial>> + : {}) : /* record */ UnionToIntersection< - { [K in keyof T]: T[K] extends BaseDict ? Flatten> : never }[keyof T] - > & { readonly [K in keyof T as JoinPath]: T[K] }; + { [K in keyof Dict]: Dict[K] extends BaseDict ? Flatten> : never }[keyof Dict] + > & { readonly [K in keyof Dict as JoinPath]: Dict[K] }; -function visitDict(flat_dict: Record, dict: BaseDict, path: string): void { +function flattenInternal(flat_dict: Record, dict: BaseDict, scope?: string): void { + const prefix = scope ? `${scope}.` : ""; for (const [key, value] of Object.entries(dict)) { - const key_path = `${path}.${key}`; + const key_path = `${prefix}${key}`; flat_dict[key_path] = value; - isDict(value) && visitDict(flat_dict, value, key_path); + isDict(value) && flattenInternal(flat_dict, value, key_path); } } @@ -70,7 +71,7 @@ function visitDict(flat_dict: Record, dict: BaseDict, path: str * This way each nested property is available as a flat key. * * @example - * ```ts + * ```typescript * const dict = { * a: { * foo: "foo", @@ -91,16 +92,14 @@ function visitDict(flat_dict: Record, dict: BaseDict, path: str * } * ``` */ -export function flatten(dict: T): Flatten { +export function flatten(dict: Dict): Flatten { const flat_dict: Record = { ...dict }; - for (const [key, value] of Object.entries(dict)) { - isDict(value) && visitDict(flat_dict, value, key); - } - return flat_dict as Flatten; + flattenInternal(flat_dict, dict) + return flat_dict as Flatten; } -export type Prefixed = { - readonly [K in keyof T as `${P}.${K & (string | number)}`]: T[K]; +export type Prefixed = { + readonly [K in keyof Dict as `${P}.${K & (string | number)}`]: Dict[K]; }; /** @@ -109,7 +108,7 @@ export type Prefixed = { * Useful for namespacing a dictionary when combining multiple dictionaries. * * @example - * ```ts + * ```typescript * const dict = { * hello: "hello", * goodbye: "goodbye", @@ -125,10 +124,10 @@ export type Prefixed = { * } * ``` */ -export const prefix: ( - dict: T, +export const prefix: ( + dict: Dict, prefix: P, -) => Prefixed = (dict: BaseRecordDict, prefix: string): any => { +) => Prefixed = (dict: BaseRecordDict, prefix: string): any => { prefix += "."; const result: Record = {}; for (const [key, value] of Object.entries(dict)) { @@ -137,20 +136,21 @@ export const prefix: ( return result; }; -export type BaseTemplateArgs = Record; +export type BaseTemplateArgs = Record; /** * A string branded with arguments needed to resolve the template. */ -export type Template = string & { __template_args: T }; +export type Template = string & { _template_args: T }; export type TemplateArgs> = T extends Template ? R : never; /** * Identity function that returns the same string branded as {@link Template} with the arguments needed to resolve the template. + * Keep in mind that you may only use arguments of a type that is supported by your template resolver * * @example - * ```ts + * ```typescript * const template = i18n.template<{ name: string }>("hello {{ name }}!"); * * // same as @@ -159,72 +159,73 @@ export type TemplateArgs> = T extends Template */ export const template = (source: string): Template => source as any; + /** * Resolve a {@link Template} with the provided {@link TemplateArgs}. */ -export type TemplateResolver = ( - template: T, - ...args: ResolveArgs -) => O; +export type TemplateResolver = ( + template: Dict, + ...args: ResolveArgs +) => Out; /** * Simple template resolver that replaces `{{ key }}` with the value of `args.key`. * * @example - * ```ts + * ```typescript * resolveTemplate("hello {{ name }}!", { name: "John" }); * // => "hello John!" * ``` */ -export const resolveTemplate: TemplateResolver = (string: string, args?: BaseTemplateArgs) => { - if (args) - for (const [key, value] of Object.entries(args)) - string = string.replace(new RegExp(`{{\\s*${key}\\s*}}`, "g"), value as string); - return string; +export const resolveTemplate: TemplateResolver = (value: string, args?: BaseTemplateArgs) => { + if (typeof value == "string" && args) + for (const [key, argValue] of Object.entries(args)) + value = value.replace(new RegExp(`{{\\s*${key}\\s*}}`, "g"), argValue as string); + return value; }; /** * Template resolver that does nothing. It's used as a fallback when no template resolver is provided. */ -export const identityResolveTemplate = (v => v) as TemplateResolver; +export const identityResolveTemplate = (v => v) as TemplateResolver; -export type Resolved = T extends (...args: any[]) => infer R ? R : T extends O ? O : T; +export type Resolved = Value extends (...args: any[]) => infer R ? R : Value extends string ? Out : Value; -export type ResolveArgs = T extends (...args: infer A) => any +export type ResolveArgs = Value extends (...args: infer A) => any ? A - : T extends Template - ? [args: R] - : T extends O - ? [args?: BaseTemplateArgs] - : []; + : Value extends Template + ? (R extends BaseTemplateArgs ? [args: R] : [`Translator resolver doesn't fully support the argument types used in this template`]) + : Value extends BaseRecordDict ? + [] : + [args?: BaseTemplateArgs]; -export type Resolver = (...args: ResolveArgs) => Resolved; -export type NullableResolver = (...args: ResolveArgs) => Resolved | undefined; +export type Resolver = (...args: ResolveArgs) => Resolved; +export type NullableResolver = (...args: ResolveArgs) => Resolved | undefined; -export type Translator = ( +export type Translator = ( path: K, - ...args: ResolveArgs -) => Resolved; + ...args: ResolveArgs +) => Resolved; -export type NullableTranslator = ( +export type NullableTranslator = ( path: K, - ...args: ResolveArgs -) => Resolved | undefined; + ...args: ResolveArgs +) => Resolved | undefined; /** * Create a translator function that will resolve the path in the dictionary and return the value. * - * If the value is a function it will call it with the provided arguments. + * If the value is a function, it will call it with the provided arguments. * - * If the value is a string it will resolve the template using {@link resolveTemplate} with the provided arguments. + * If the value is a string, it will resolve the template using {@link resolveTemplate} with the provided arguments. * - * Otherwise it will return the value as is. + * Otherwise, it will return the value as is. * * @param dict A function that returns the dictionary to use for translation. Will be called on each translation. * @param resolveTemplate A function that will resolve the template. Defaults to {@link identityResolveTemplate}. * * @example - * ```ts + * ```typescript * const dict = { * hello: "hello {{ name }}!", * goodbye: (name: string) => `goodbye ${name}!`, @@ -241,47 +242,57 @@ export type NullableTranslator = "meat" * ``` */ -export function translator( - dict: () => T, - resolveTemplate?: TemplateResolver, -): Translator; -export function translator( - dict: () => T | undefined, - resolveTemplate?: TemplateResolver, -): NullableTranslator; -export function translator( - dict: () => T | undefined, - resolveTemplate: TemplateResolver = identityResolveTemplate, -): any { - return (path: string, ...args: any[]) => { - if (path[0] === ".") path = path.slice(1); - +export function translator( + dict: () => Dict, + resolveTemplate?: TemplateResolver, +): Translator; +export function translator( + dict: () => Dict | undefined, + resolveTemplate?: TemplateResolver, +): NullableTranslator; +export function translator( + dict: () => Dict | undefined, + resolveTemplate: TemplateResolver = identityResolveTemplate, +): Translator { + return (path, ...args) => { const value = dict()?.[path]; - switch (typeof value) { case "function": return value(...args); case "string": - return resolveTemplate(value, args[0]); + // @ts-expect-error: The types don't work out well, because of the template resolution, + // but it shouldn't matter, because in the implementation we don't care about that + return resolveTemplate(value, args[0]) default: return value; } }; } -export type Scopes = { - [K in T]: K extends `${infer S}.${infer R}` ? S | `${S}.${Scopes}` : never; -}[T]; +type InternalSplit = + S extends `${infer T}${D}${infer U}` ? + "" | `${D}${T}${InternalSplit}` + : ""; -export type Scoped> = { - readonly [K in keyof T as K extends `${S}.${infer R}` ? R : never]: T[K]; -}; + +type Split = + S extends `${infer T}${D}${infer U}` ? + `${T}${InternalSplit}` + : ""; + +export type Scopes = "" | undefined | null | Split, "."> + +// this type does not work with a union type as Scope, +// it only works with a single string +export type Scoped> = + "" | undefined | null extends Scope ? Dict : + { readonly [P in keyof Dict as P extends `${Scope}.${infer Rest}` ? Rest : never]: Dict[P] } /** * Scopes the provided {@link Translator} to the given {@link scope}. * * @example - * ```ts + * ```typescript * const dict = { * greetings: { * hello: "hello {{ name }}!", @@ -300,31 +311,32 @@ export type Scoped> * greetings("goodbye", "John") // => undefined * ``` */ -export function scopedTranslator>( - translator: Translator, - scope: S, -): Translator, O>; -export function scopedTranslator>( - translator: NullableTranslator, - scope: S, -): NullableTranslator, O>; -export function scopedTranslator( - translator: Translator, - scope: string, -): Translator, O> { - return (path, ...args) => translator(`${scope}.${path}`, ...args); +export function scopedTranslator>( + translator: Translator, + scope: Scope, +): Translator, Out, Args>; +export function scopedTranslator>( + translator: NullableTranslator, + scope: Scope, +): NullableTranslator, Out, Args>; +export function scopedTranslator>( + translator: Translator, + scope: Scope, +): Translator, Out, Args> { + // @ts-expect-error: Caused due to the scope not supporting union types + return ((path, ...args) => translator(`${scope}.${path}` as keyof Dict, ...args)); } -export type ChainedTranslator = { - readonly [K in keyof T]: T[K] extends BaseRecordDict - ? ChainedTranslator - : Resolver; +export type ChainedTranslator = { + readonly [K in keyof Dict]: Dict[K] extends BaseRecordDict + ? ChainedTranslator + : Resolver; }; -export type NullableChainedTranslator = { - readonly [K in keyof T]: T[K] extends BaseRecordDict - ? NullableChainedTranslator - : NullableResolver; +export type NullableChainedTranslator = { + readonly [K in keyof Dict]: Dict[K] extends BaseRecordDict + ? NullableChainedTranslator + : NullableResolver; }; /** @@ -333,8 +345,9 @@ export type NullableChainedTranslator = { * @param init_dict The initial dictionary used for getting the structure of nested objects. * @param translate {@link Translator} function that will resolve the path in the dictionary and return the value. * + * @param scope The initial path to use for the chained translator. * @example - * ```ts + * ```typescript * const dict = { * greetings: { * hello: "hello {{ name }}!", @@ -353,34 +366,30 @@ export type NullableChainedTranslator = { * chained.goodbye("John") // => "goodbye John!" * ``` */ -export function chainedTranslator( - init_dict: T, - translate: Translator, - path?: string, -): ChainedTranslator; -export function chainedTranslator( - init_dict: T, - translate: NullableTranslator, - path?: string, -): NullableChainedTranslator; -export function chainedTranslator( - init_dict: T, - translate: Translator, - path: string = "", -): any { - const result: any = { ...init_dict }; +export function chainedTranslator>( + init_dict: Dict, + translate: Translator, + scope?: Scope, +): ChainedTranslator, Out, Args>; +export function chainedTranslator>( + init_dict: Dict, + translate: NullableTranslator, + scope?: Scope, +): NullableChainedTranslator, Out, Args>; +export function chainedTranslator>( + init_dict: Dict, + translate: Translator, + scope?: Scope, +): ChainedTranslator, Out, Args> { + const result: any = {}; + const prefix = scope ? `${scope}.` : ""; for (const [key, value] of Object.entries(init_dict)) { - const key_path = `${path}.${key}`; - - result[key] = isRecordDict(value) - ? chainedTranslator(value, translate, key_path) - : (...args: any[]) => - translate( - key_path, - // @ts-expect-error - ...args, - ); + const key_path = `${prefix}${key}`; + + result[key] = isRecordDict(value) ? + chainedTranslator(value, translate, key_path as Scopes) : + (...args: ResolveArgs) => translate(key_path as keyof Dict, ...args); } return result; @@ -391,8 +400,9 @@ export function chainedTranslator( * * @param translate {@link Translator} function that will resolve the path in the dictionary and return the value. * + * @param scope The initial path to use for the chained translator. * @example - * ```ts + * ```typescript * const dict = { * greetings: { * hello: "hello {{ name }}!", @@ -411,42 +421,46 @@ export function chainedTranslator( * proxy.goodbye("John") // => "goodbye John!" * ``` */ -export function proxyTranslator( - translate: Translator, - path?: string, -): ChainedTranslator; -export function proxyTranslator( - translate: NullableTranslator, - path?: string, -): NullableChainedTranslator; -export function proxyTranslator( - translate: Translator, - path: string = "", -): any { - return new Proxy(translate.bind(void 0, path), new Traps(translate, path)); +export function proxyTranslator>( + translate: Translator, + scope?: Scope, +): ChainedTranslator, Out, Args>; +export function proxyTranslator>( + translate: NullableTranslator, + scope?: Scope, +): NullableChainedTranslator, Out, Args>; +export function proxyTranslator>( + translate: Translator, + scope?: Scope, +): ChainedTranslator, Out, Args> { + return new Proxy(translate.bind(void 0, scope ?? ""), new Traps(translate, scope)); } -class Traps { +class Traps { + private readonly prefix: string; + constructor( - private readonly translate: Translator, - private readonly path: string, - ) {} + private readonly translate: Translator, + scope: Scopes, + ) { + this.prefix = scope ? `${scope}.` : ""; + } - get(target: any, prop: PropertyKey): any { + get(target: Translator, prop: PropertyKey): any { if (typeof prop !== "string") return Reflect.get(target, prop); - return (proxyTranslator as any)(this.translate, `${this.path}.${prop}`); + return (proxyTranslator as any)(this.translate, `${this.prefix}${prop}`); } - has(target: any, prop: PropertyKey): boolean { + has(target: Translator, prop: PropertyKey): boolean { if (typeof prop !== "string") return Reflect.has(target, prop); - return (proxyTranslator as any)(this.translate, `${this.path}.${prop}`) !== undefined; + return (proxyTranslator as any)(this.translate, `${this.prefix}${prop}`) !== undefined; } getOwnPropertyDescriptor(target: any, prop: PropertyKey): PropertyDescriptor | undefined { if (typeof prop !== "string") return Reflect.getOwnPropertyDescriptor(target, prop); return { enumerable: true, - get: () => (proxyTranslator as any)(this.translate, `${this.path}.${prop}`), + get: () => (proxyTranslator as any)(this.translate, `${this.prefix}${prop}`), }; } } diff --git a/packages/i18n/test/index.test.ts b/packages/i18n/test/index.test.ts index 0fcf5d5dc..254782c2e 100644 --- a/packages/i18n/test/index.test.ts +++ b/packages/i18n/test/index.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from "vitest"; +import { describe, expect, expectTypeOf, test } from "vitest"; import * as i18n from "../src/index.js"; import { createEffect, createResource, createRoot, createSignal } from "solid-js"; import { Locale, en_dict, pl_dict } from "./setup.jsx"; @@ -108,6 +108,7 @@ describe("scopedTranslator", () => { const _t = i18n.translator(() => flat_dict, i18n.resolveTemplate); const t = i18n.scopedTranslator(_t, "data"); + const nt = i18n.scopedTranslator(_t, "data.currency"); test("initial", () => { expect(t("class")).toBe(en_dict.data.class); @@ -126,6 +127,13 @@ describe("scopedTranslator", () => { expect(t("users")).toEqual(pl_dict.data.users); expect(t("formatList", ["John", "Kate", "Tester"])).toBe("John, Kate i Tester"); }); + + test("nested", () => { + flat_dict = i18n.flatten(pl_dict); + + expect(nt("name")).toBe("złoty"); + expect(nt("to.usd")).toBe(0.27); + }); }); Object.entries({ @@ -205,3 +213,24 @@ describe("reactive", () => { dispose(); }); }); + +describe("resolver custom result", () => { + + const customResolve: i18n.TemplateResolver = (value, ...args) => { + return value.length + } + + test("with translator", () => { + const dict = i18n.flatten(en_dict) + const t = i18n.translator(() => dict, customResolve) + + const dollar_length = t("data.currency.name") + const one_length = t("numbers.1") + + expectTypeOf(dollar_length).toEqualTypeOf() + expectTypeOf(one_length).toEqualTypeOf() + + expect(dollar_length).toBe(6) + expect(one_length).toBe(3) + }); +});