diff --git a/packages/core/__typetests__/index.tst.ts b/packages/core/__typetests__/index.tst.ts index ab89b2126..595ca59a6 100644 --- a/packages/core/__typetests__/index.tst.ts +++ b/packages/core/__typetests__/index.tst.ts @@ -1,6 +1,14 @@ -import { i18n } from "@lingui/core" +import { i18n } from "../src" import { expect } from "tstyche" +const supportedValues = { + deadline: new Date(), + total: BigInt(1), + active: false, + optional: null, + missing: undefined, +} + expect(i18n._("message.id")).type.toBe() expect( i18n._({ @@ -15,6 +23,18 @@ expect( { message: "Hello {name}", comment: "", formats: {} }, ), ).type.toBe() +expect(i18n._).type.toBeCallableWith("message.id", supportedValues) +expect(i18n._).type.not.toBeCallableWith("message.id", { + payload: { nested: true }, +}) +expect(i18n._).type.toBeCallableWith({ + id: "message.id", + values: supportedValues, +}) +expect(i18n._).type.not.toBeCallableWith({ + id: "message.id", + values: { payload: { nested: true } }, +}) expect(i18n._).type.not.toBeCallableWith( // cannot use message descriptor together with rest of params { diff --git a/packages/core/macro/__typetests__/index.tst.ts b/packages/core/macro/__typetests__/index.tst.ts index 86f3b4905..3d9ee1dd7 100644 --- a/packages/core/macro/__typetests__/index.tst.ts +++ b/packages/core/macro/__typetests__/index.tst.ts @@ -15,6 +15,8 @@ const name = "Jack" const user = { name: "Jack" } const i18n: I18n = null as any const numberValue = 2 +const bigintValue = BigInt(1) +const deadline = new Date() // simple expect(t`Hello world`).type.toBe() @@ -28,6 +30,9 @@ expect(t`Hello ${ph({ name: user.name })}`).type.toBe() // allow numbers expect(t`Hello ${numberValue}`).type.toBe() +expect( + t`Hello ${bigintValue} ${deadline} ${{ active: false }}`, +).type.toBe() // with custom i18n expect(t(i18n)`With custom i18n instance`).type.toBe() @@ -98,6 +103,8 @@ expect( // allow numbers expect(msg`Hello ${numberValue}`).type.toBe() expect(defineMessage`Hello ${numberValue}`).type.toBe() +expect(ph({ deadline })).type.toBe() +expect(ph).type.not.toBeCallableWith({ payload: { nested: true } }) expect( defineMessage({ diff --git a/packages/core/macro/index.d.mts b/packages/core/macro/index.d.mts index 050c95417..6ab1e1101 100644 --- a/packages/core/macro/index.d.mts +++ b/packages/core/macro/index.d.mts @@ -1,4 +1,8 @@ -import type { I18n, MessageDescriptor } from "@lingui/core" +import type { + I18n, + MessageDescriptor, + MessagePlaceholderValue, +} from "@lingui/core" export type ChoiceOptions = { /** Offset of value when calculating plural forms */ @@ -56,10 +60,11 @@ export type MacroMessageDescriptor = ( export function t(descriptor: MacroMessageDescriptor): string export type LabeledExpression = Record +export type LabeledMessagePlaceholder = + LabeledExpression export type MessagePlaceholder = - | string - | number - | LabeledExpression + | MessagePlaceholderValue + | LabeledMessagePlaceholder /** * Translates a template string using the global I18n instance @@ -109,6 +114,9 @@ export function t(i18n: I18n): { (descriptor: MacroMessageDescriptor): string } +export type PluralValue = number | string | LabeledExpression +export type SelectValue = string | LabeledExpression + /** * Pluralize a message * @@ -125,7 +133,7 @@ export function t(i18n: I18n): { * @param options Object with available plural forms */ export function plural( - value: number | string | LabeledExpression, + value: PluralValue, options: ChoiceOptions ): string @@ -150,7 +158,7 @@ export function plural( * @param options Object with available plural forms */ export function selectOrdinal( - value: number | string | LabeledExpression, + value: PluralValue, options: ChoiceOptions ): string @@ -181,7 +189,7 @@ type SelectOptions = { * @param choices */ export function select( - value: string | LabeledExpression, + value: SelectValue, choices: SelectOptions ): string @@ -232,4 +240,4 @@ export const msg: typeof defineMessage /** * Helps to define a name for a variable in the message */ -export function ph(def: LabeledExpression): string \ No newline at end of file +export function ph(def: LabeledMessagePlaceholder): string diff --git a/packages/core/src/i18n.ts b/packages/core/src/i18n.ts index 1f8311016..8e7ae89e0 100644 --- a/packages/core/src/i18n.ts +++ b/packages/core/src/i18n.ts @@ -26,7 +26,16 @@ export type Formats = Record< Intl.DateTimeFormatOptions | Intl.NumberFormatOptions > -export type Values = Record +export type MessagePlaceholderValue = + | string + | number + | bigint + | boolean + | null + | undefined + | Date + +export type Values = Record export type UncompiledMessage = string export type Messages = Record @@ -37,7 +46,7 @@ export type MessageDescriptor = { id: string comment?: string message?: string - values?: Record + values?: Values } export type MissingMessageEvent = { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index bdf1b454e..ea680499f 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -3,10 +3,12 @@ export { setupI18n, I18n } from "./i18n" export type { AllMessages, MessageDescriptor, + MessagePlaceholderValue, Messages, Locale, Locales, MessageOptions, + Values, } from "./i18n" // Default i18n object diff --git a/packages/react/__typetests__/index.tst.tsx b/packages/react/__typetests__/index.tst.tsx new file mode 100644 index 000000000..ee25b5090 --- /dev/null +++ b/packages/react/__typetests__/index.tst.tsx @@ -0,0 +1,20 @@ +import { expect } from "tstyche" +import type { TransProps } from "../src" + +type Values = NonNullable + +expect({ + name: "Tim", + count: 1, + total: BigInt(1), + active: false, + deadline: new Date(), + optional: null, + missing: undefined, + richName: Tim, + parts: ["Mr. ", Tim], +}).type.toBeAssignableTo() + +expect({ + payload: { nested: true }, +}).type.not.toBeAssignableTo() diff --git a/packages/react/macro/__typetests__/index.tst.tsx b/packages/react/macro/__typetests__/index.tst.tsx index 6e627fcd0..758f8653a 100644 --- a/packages/react/macro/__typetests__/index.tst.tsx +++ b/packages/react/macro/__typetests__/index.tst.tsx @@ -32,6 +32,15 @@ m = ( m = m = Hello {{ username: user.name }} +m = Hello {{ deadline: new Date() }} +m = ( + + { + // @ts-expect-error: nested objects are not supported as interpolation values + { payload: { nested: true } } + } + +) m = ( @@ -139,6 +148,9 @@ m = +// @ts-expect-error: labeled value for Select should be string only +m = +type TransChildren = ReactNode | LabeledMessagePlaceholder type TransProps = { children: TransChildren | TransChildren[] } & CommonProps type PluralChoiceProps = { - value: string | number | LabeledExpression + value: PluralValue /** Offset of value when calculating plural forms */ offset?: number zero?: ReactNode @@ -33,7 +38,7 @@ type PluralChoiceProps = { } & CommonProps type SelectChoiceProps = { - value: string | LabeledExpression + value: SelectValue /** Catch-all option */ other: ReactNode [option: `_${string}`]: ReactNode @@ -135,4 +140,4 @@ export function useLingui(): Omit & { (descriptor: MacroMessageDescriptor): string (literals: TemplateStringsArray, ...placeholders: any[]): string } -} \ No newline at end of file +} diff --git a/packages/react/src/Trans.test.tsx b/packages/react/src/Trans.test.tsx index 00b6c1c49..515d1947d 100644 --- a/packages/react/src/Trans.test.tsx +++ b/packages/react/src/Trans.test.tsx @@ -330,6 +330,64 @@ describe("Trans component", () => { expect(translation).toEqual("1,00 €") }) + it("should preserve Date and bigint values for i18n interpolation", () => { + const deadline = new Date("2014-12-06T17:40:00.000Z") + const translation = html( + , + }} + />, + ) + + expect(translation).toEqual( + `It starts on ${deadline.toString()}, count 1 John`, + ) + }) + + it.each([ + ["false", false], + ["true", true], + ["null", null], + ["undefined", undefined], + ] as const)( + "should omit %s values during Trans interpolation", + (_label, value) => { + const translation = html( + , + ) + + expect(translation).toEqual("Value ") + }, + ) + + it("should preserve __proto__ values for i18n interpolation", () => { + const translation = html( + , + ) + + expect(translation).toEqual("Hello John") + }) + it("should render plural", () => { const render = (count: number) => html( diff --git a/packages/react/src/TransNoContext.tsx b/packages/react/src/TransNoContext.tsx index 24b551b4f..ce3c43671 100644 --- a/packages/react/src/TransNoContext.tsx +++ b/packages/react/src/TransNoContext.tsx @@ -1,5 +1,5 @@ import { formatElements } from "./format" -import type { I18n, MessageOptions } from "@lingui/core" +import type { I18n, MessageOptions, Values } from "@lingui/core" export type TransRenderProps = { id: string @@ -20,11 +20,18 @@ export type TransRenderCallbackOrComponent = render?: never } +type CoreInterpolationValue = string | number | bigint | Date +type TransChildValue = Exclude + +export type TransValue = CoreInterpolationValue | TransChildValue +export type TransValues = Record +type TransComponents = Record + export type TransProps = { id: string message?: string - values?: Record - components?: { [key: string]: React.ElementType | any } + values?: TransValues + components?: TransComponents formats?: MessageOptions["formats"] comment?: string } & TransRenderCallbackOrComponent @@ -115,7 +122,12 @@ const RenderChildren = ({ children }: TransRenderProps) => { return children } -const getInterpolationValuesAndComponents = (props: TransProps) => { +const getInterpolationValuesAndComponents = ( + props: TransProps, +): { + values: Values | undefined + components: TransComponents | undefined +} => { if (!props.values) { return { values: undefined, @@ -123,8 +135,11 @@ const getInterpolationValuesAndComponents = (props: TransProps) => { } } - const values = { ...props.values } - const components = { ...props.components } + const values: Values = Object.create(null) + const components: TransComponents = { + ...props.components, + } + let nextIndex = getNextPlaceholderIndex(components) /* Replace values placeholders with and add values to `components`. This makes them processed as JSX children and follow JSX semantics. @@ -142,14 +157,31 @@ const getInterpolationValuesAndComponents = (props: TransProps) => { Related discussion: https://github.com/lingui/js-lingui/issues/183 */ Object.entries(props.values).forEach(([key, valueForKey]) => { - // simple scalars should be processed as values to be able to apply formatting - if (typeof valueForKey === "string" || typeof valueForKey === "number") { + if (isCoreInterpolationValue(valueForKey)) { + values[key] = valueForKey return } - const index = Object.keys(components).length - // react components, arrays, falsy values, all should be processed as JSX children - components[index] = <>{valueForKey} - values[key] = `<${index}/>` + + // `Trans` intentionally keeps React child semantics for these values + // instead of flowing them through core string interpolation. + components[nextIndex] = <>{valueForKey} + values[key] = `<${nextIndex}/>` + nextIndex += 1 }) return { values, components } } + +function getNextPlaceholderIndex(components: TransComponents): number { + return Object.keys(components).length +} + +function isCoreInterpolationValue( + value: TransValue, +): value is CoreInterpolationValue { + return ( + typeof value === "string" || + typeof value === "number" || + typeof value === "bigint" || + value instanceof Date + ) +}