Fix i18n types (#33305)

Without the fix the return type would be `string` in cases where it should be `ReactNode`
This commit is contained in:
Michael Telatynski
2026-04-27 16:09:55 +01:00
committed by GitHub
parent 03b730db58
commit 7766ae92d7
6 changed files with 621 additions and 574 deletions
+2 -2
View File
@@ -12,7 +12,7 @@ import _ from "lodash";
import { import {
_t, _t,
normalizeLanguageKey, normalizeLanguageKey,
type IVariables, type StringVariables,
KEY_SEPARATOR, KEY_SEPARATOR,
getLangsJson, getLangsJson,
registerTranslations, registerTranslations,
@@ -72,7 +72,7 @@ export class UserFriendlyError extends Error {
public constructor( public constructor(
message: TranslationKey, message: TranslationKey,
substitutionVariablesAndCause?: Omit<IVariables, keyof ErrorOptions> | ErrorOptions, substitutionVariablesAndCause?: Omit<StringVariables, keyof ErrorOptions> | ErrorOptions,
) { ) {
// Prevent "Could not find /%\(cause\)s/g in x" logs to the console by removing it from the list // Prevent "Could not find /%\(cause\)s/g in x" logs to the console by removing it from the list
const { cause, ...substitutionVariables } = substitutionVariablesAndCause ?? {}; const { cause, ...substitutionVariables } = substitutionVariablesAndCause ?? {};
File diff suppressed because it is too large Load Diff
+18 -6
View File
@@ -33,13 +33,25 @@ export type SubstitutionValue = number | string | ReactNode | ((sub: string) =>
* Variables to interpolate into a translation. * Variables to interpolate into a translation.
* @public * @public
*/ */
export type Variables = { export type Variables = StringVariables | RichVariables;
/**
* The number of items to count for pluralised translations /**
*/ * Variables that are guaranteed to only contain primitive (string-safe) values
* @public
*/
export interface StringVariables {
count?: number;
[key: string]: number | string | null | undefined;
}
/**
* Variables that may contain ReactNodes or functions, requiring a ReactNode return
* @public
*/
export interface RichVariables {
count?: number; count?: number;
[key: string]: SubstitutionValue; [key: string]: SubstitutionValue;
}; }
/** /**
* Tags to interpolate into a translation, where the value is a ReactNode or a function that returns a ReactNode. * Tags to interpolate into a translation, where the value is a ReactNode or a function that returns a ReactNode.
@@ -68,7 +80,7 @@ export interface I18nApi {
* @param key - The key to translate * @param key - The key to translate
* @param variables - Optional variables to interpolate into the translation * @param variables - Optional variables to interpolate into the translation
*/ */
translate(this: void, key: keyof Translations, variables?: Variables): string; translate(this: void, key: keyof Translations, variables?: StringVariables): string;
/** /**
* Perform a translation, with optional variables * Perform a translation, with optional variables
* @param key - The key to translate * @param key - The key to translate
+9 -1
View File
@@ -8,7 +8,15 @@ Please see LICENSE files in the repository root for full details.
export { ModuleLoader, ModuleIncompatibleError } from "./loader"; export { ModuleLoader, ModuleIncompatibleError } from "./loader";
export type { Api, Module, ModuleFactory } from "./api"; export type { Api, Module, ModuleFactory } from "./api";
export type { Config, ConfigApi } from "./api/config"; export type { Config, ConfigApi } from "./api/config";
export type { I18nApi, Variables, Translations, SubstitutionValue, Tags } from "./api/i18n"; export type {
I18nApi,
Variables,
StringVariables,
RichVariables,
Translations,
SubstitutionValue,
Tags,
} from "./api/i18n";
export type * from "./models/event"; export type * from "./models/event";
export type * from "./models/Room"; export type * from "./models/Room";
export type * from "./api/composer"; export type * from "./api/composer";
@@ -8,6 +8,7 @@ Please see LICENSE files in the repository root for full details.
import { import {
type I18nApi as II18nApi, type I18nApi as II18nApi,
type Variables, type Variables,
type StringVariables,
type Translations, type Translations,
type Tags, type Tags,
} from "@element-hq/element-web-module-api"; } from "@element-hq/element-web-module-api";
@@ -48,11 +49,11 @@ export class I18nApi implements II18nApi {
* @param variables - Optional variables to interpolate into the translation * @param variables - Optional variables to interpolate into the translation
* @param tags - Optional tags to interpolate into the translation * @param tags - Optional tags to interpolate into the translation
*/ */
public translate(this: void, key: TranslationKey, variables?: Variables): string; public translate(this: void, key: TranslationKey, variables?: StringVariables): string;
public translate(this: void, key: TranslationKey, variables: Variables | undefined, tags: Tags): React.ReactNode; public translate(this: void, key: TranslationKey, variables: Variables | undefined, tags: Tags): React.ReactNode;
public translate(this: void, key: TranslationKey, variables?: Variables, tags?: Tags): React.ReactNode | string { public translate(this: void, key: TranslationKey, variables?: Variables, tags?: Tags): React.ReactNode | string {
if (tags) return _t(key, variables, tags); if (tags) return _t(key, variables, tags);
return _t(key, variables); return _t(key, variables as Variables);
} }
public humanizeTime = (timeMillis: number): string => humanizeTime(timeMillis, this); public humanizeTime = (timeMillis: number): string => humanizeTime(timeMillis, this);
@@ -141,11 +141,20 @@ function safeCounterpartTranslate(text: string, variables?: IVariables): { trans
*/ */
type SubstitutionValue = number | string | React.ReactNode | ((sub: string) => React.ReactNode); type SubstitutionValue = number | string | React.ReactNode | ((sub: string) => React.ReactNode);
export interface IVariables { // Variables that are guaranteed to only contain primitive (string-safe) values
export interface StringVariables {
count?: number;
[key: string]: number | string | null | undefined;
}
// Variables that may contain ReactNodes or functions, requiring a ReactNode return
export interface RichVariables {
count?: number; count?: number;
[key: string]: SubstitutionValue; [key: string]: SubstitutionValue;
} }
export type IVariables = StringVariables | RichVariables;
export type Tags = Record<string, SubstitutionValue>; export type Tags = Record<string, SubstitutionValue>;
export type TranslatedString = string | React.ReactNode; export type TranslatedString = string | React.ReactNode;
@@ -168,13 +177,15 @@ const annotateStrings = (result: TranslatedString, translationKey: TranslationKe
} }
}; };
export function _t(text: TranslationKey, variables?: IVariables): string; // Returns string only when variables are primitives and no tags are provided
export function _t(text: TranslationKey, variables: IVariables | undefined, tags: Tags): React.ReactNode; export function _t(text: TranslationKey, variables?: StringVariables): string;
export function _t(text: TranslationKey, variables?: IVariables, tags?: Tags): TranslatedString { // Returns ReactNode when variables contain ReactNodes (even without tags)
// The translation returns text so there's no XSS vector here (no unsafe HTML, no code execution) export function _t(text: TranslationKey, variables: RichVariables): React.ReactNode;
// Returns ReactNode when tags are provided (regardless of variables)
export function _t(text: TranslationKey, variables: RichVariables | undefined, tags: Tags): React.ReactNode;
export function _t(text: TranslationKey, variables?: StringVariables | RichVariables, tags?: Tags): TranslatedString {
const { translated } = safeCounterpartTranslate(text, variables); const { translated } = safeCounterpartTranslate(text, variables);
const substituted = substitute(translated, variables, tags); const substituted = substitute(translated, variables, tags);
return annotateStrings(substituted, text); return annotateStrings(substituted, text);
} }
@@ -197,8 +208,9 @@ export function lookupString(key: TranslationKey): string {
* or translation used a fallback locale, otherwise a string * or translation used a fallback locale, otherwise a string
*/ */
// eslint-next-line @typescript-eslint/naming-convention // eslint-next-line @typescript-eslint/naming-convention
export function _tDom(text: TranslationKey, variables?: IVariables): TranslatedString; export function _tDom(text: TranslationKey, variables?: StringVariables): string;
export function _tDom(text: TranslationKey, variables: IVariables, tags: Tags): React.ReactNode; export function _tDom(text: TranslationKey, variables: RichVariables): React.ReactNode;
export function _tDom(text: TranslationKey, variables: RichVariables, tags: Tags): React.ReactNode;
export function _tDom(text: TranslationKey, variables?: IVariables, tags?: Tags): TranslatedString { export function _tDom(text: TranslationKey, variables?: IVariables, tags?: Tags): TranslatedString {
// The translation returns text so there's no XSS vector here (no unsafe HTML, no code execution) // The translation returns text so there's no XSS vector here (no unsafe HTML, no code execution)
const { translated, isFallback } = safeCounterpartTranslate(text, variables); const { translated, isFallback } = safeCounterpartTranslate(text, variables);
@@ -233,8 +245,9 @@ export function sanitizeForTranslation(text: string): string {
* *
* @return a React <span> component if any non-strings were used in substitutions, otherwise a string * @return a React <span> component if any non-strings were used in substitutions, otherwise a string
*/ */
export function substitute(text: string, variables?: IVariables): string; export function substitute(text: string, variables?: StringVariables): string;
export function substitute(text: string, variables: IVariables | undefined, tags: Tags | undefined): string; export function substitute(text: string, variables?: RichVariables): React.ReactNode;
export function substitute(text: string, variables: RichVariables | undefined, tags: Tags | undefined): string;
export function substitute(text: string, variables?: IVariables, tags?: Tags): string | React.ReactNode { export function substitute(text: string, variables?: IVariables, tags?: Tags): string | React.ReactNode {
let result: React.ReactNode | string = text; let result: React.ReactNode | string = text;