@discord/intl 0.1.0-rc.1 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -1,3 +1,32 @@
1
+ import { IntlManager } from './intl-manager';
2
+ import { IntlManagerReact } from './react';
1
3
  export { IntlManager, createLoader, DEFAULT_LOCALE } from './intl-manager';
2
4
  export { IntlManagerReact } from './react';
3
5
  export type * from './types.d.ts';
6
+ /**
7
+ * A type representing any message that has been formatted using one of the
8
+ * default formatting methods in the `@discord/intl` system. Use this type
9
+ * when you don't care about whether a message is static or contains rich text
10
+ * or has dynamically-formatted values, when you need to handle both in tandem,
11
+ * or when you need a generic return type for a function returning "any
12
+ * any-already formatted message".
13
+ *
14
+ * Generally, this is like `React.ReactNode`, accepting both strings and rich
15
+ * `ReactElement`s, but it is more specific to disallow nullish values and
16
+ * other special React elements like `ReactPortal`. Seeing this type, you can
17
+ * be confident that the value is intended to be the result of formatting an
18
+ * intl message, even if the actual value comes from elsewhere (like a
19
+ * user-generated string).
20
+ */
21
+ export type ReactIntlMessage = string | ReturnType<IntlManagerReact<any>['format']>;
22
+ /**
23
+ * The return value of `formatToParts` from `@discord/intl`, this type
24
+ * represents any AST structure for a message rendered using this system.
25
+ * The AST generally follows a Markdown-like structure, with text nodes
26
+ * interspersed within and around rich text formatting nodes.
27
+ *
28
+ * ASTs are created _after_ all placeholders have been filled by values
29
+ * from a call to `formatToParts`, meaning they are intended to be fully static
30
+ * structures passed around for custom rendering functions to use.
31
+ */
32
+ export type IntlMessageAst = ReturnType<IntlManager<any, any>['formatToParts']>;
@@ -1,12 +1,21 @@
1
1
  import { IntlShape } from '@formatjs/intl';
2
2
  import { LocaleId, LocaleImportMap, MessageLoader } from './message-loader';
3
- import type { FormatValuesFor, RichTextElementMap, TypedIntlMessageGetter } from './types';
3
+ import type { FunctionTypeMap, IntlMessageGetter, RequiredFormatValues, RichTextElementMap, TypedIntlMessageGetter } from './types';
4
+ /**
5
+ * Types for formatting functions when calling `formatToParts`, ensuring the
6
+ * functions yield value AST nodes.
7
+ */
8
+ type AstFunctionTypes = {
9
+ link: undefined | object | ((children: object) => Array<string | any>);
10
+ hook: undefined | object | ((children: object) => Array<string | any>);
11
+ handler: undefined | object | ((children: object) => Array<string | any>);
12
+ };
4
13
  /**
5
14
  * Fallback locale used for all internationalization when an operation in the
6
15
  * requested locale is not possible.
7
16
  */
8
17
  export declare const DEFAULT_LOCALE: string;
9
- export declare class IntlManager<DefaultElements extends RichTextElementMap, DefaultValues extends keyof DefaultElements> {
18
+ export declare class IntlManager<DefaultElements extends RichTextElementMap, FunctionTypes extends FunctionTypeMap> {
10
19
  defaultLocale: string;
11
20
  currentLocale: string;
12
21
  intl: IntlShape;
@@ -42,8 +51,8 @@ export declare class IntlManager<DefaultElements extends RichTextElementMap, Def
42
51
  * use `format`, which will wrap the formatting in a React component that
43
52
  * subscribes to the current locale and state of loaded messages.
44
53
  */
45
- formatToParts<T extends TypedIntlMessageGetter<object | undefined>>(message: T): Array<string | any>;
46
- formatToParts<T extends TypedIntlMessageGetter<object | undefined>>(message: T, values: Omit<FormatValuesFor<T>, DefaultValues>): Array<string | any>;
54
+ formatToParts<T extends TypedIntlMessageGetter<undefined>>(message: T): Array<string | any>;
55
+ formatToParts<T extends IntlMessageGetter>(message: T, values: RequiredFormatValues<T, DefaultElements, AstFunctionTypes>): Array<string | any>;
47
56
  /**
48
57
  * For messages with no formatting values (i.e., plain strings, they can skip
49
58
  * a majority of the processing work and just resolve the message itself for
@@ -58,8 +67,8 @@ export declare class IntlManager<DefaultElements extends RichTextElementMap, Def
58
67
  * Format the given message with the provided values, removing any styling
59
68
  * and non-textual content from the message, returning a plain string.
60
69
  */
61
- formatToPlainString<T extends TypedIntlMessageGetter<object | undefined>>(message: T): string;
62
- formatToPlainString<T extends TypedIntlMessageGetter<object | undefined>>(message: T, values: Omit<FormatValuesFor<T>, DefaultValues>): string;
70
+ formatToPlainString<T extends TypedIntlMessageGetter<undefined>>(message: T): string;
71
+ formatToPlainString<T extends IntlMessageGetter>(message: T, values: RequiredFormatValues<T, DefaultElements, FunctionTypes>): string;
63
72
  /**
64
73
  * Similar to `formatToPlainString`, format the given message with the provided
65
74
  * values, but convert all rich text formatting back to Markdown syntax rather
@@ -67,8 +76,8 @@ export declare class IntlManager<DefaultElements extends RichTextElementMap, Def
67
76
  * can be sent through a separate Markdown renderer to get an equivalent
68
77
  * result to formatting this message directly.
69
78
  */
70
- formatToMarkdownString<T extends TypedIntlMessageGetter<object | undefined>>(message: T): string;
71
- formatToMarkdownString<T extends TypedIntlMessageGetter<object | undefined>>(message: T, values: Omit<FormatValuesFor<T>, DefaultValues>): string;
79
+ formatToMarkdownString<T extends TypedIntlMessageGetter<undefined>>(message: T): string;
80
+ formatToMarkdownString<T extends IntlMessageGetter>(message: T, values: RequiredFormatValues<T, DefaultElements, FunctionTypes>): string;
72
81
  }
73
82
  /**
74
83
  * Create a new MessageLoader, which handles lazily loading messages for
@@ -76,3 +85,4 @@ export declare class IntlManager<DefaultElements extends RichTextElementMap, Def
76
85
  * message defined in `messageKeys`.
77
86
  */
78
87
  export declare function createLoader(messageKeys: string[], localeImportMap: LocaleImportMap, defaultLocale: LocaleId): MessageLoader;
88
+ export {};
package/dist/react.d.ts CHANGED
@@ -1,15 +1,29 @@
1
1
  import * as React from 'react';
2
2
  import { IntlManager } from './intl-manager';
3
- import type { FormatValuesFor, RichTextElementMap, TypedIntlMessageGetter } from './types';
4
- export declare class IntlManagerReact<DefaultElements extends RichTextElementMap, DefaultValues extends keyof DefaultElements> extends IntlManager<DefaultElements, DefaultValues> {
3
+ import type { IntlMessageGetter, RequiredFormatValues, RichTextElementMap, TypedIntlMessageGetter } from './types';
4
+ type ReactHandlerEvent = React.MouseEvent | React.KeyboardEvent;
5
+ type ReactClickHandler = (e: ReactHandlerEvent) => void;
6
+ type ReactFunctionTypes = {
7
+ link: undefined | ((content: React.ReactNode | React.ReactNode[], key: string) => React.ReactNode);
8
+ hook: undefined | ((content: React.ReactNode | React.ReactNode[], key: string) => React.ReactNode);
9
+ handler: undefined | ReactClickHandler | {
10
+ onClick: ReactClickHandler;
11
+ } | {
12
+ onContextMenu: ReactClickHandler;
13
+ } | {
14
+ onClick: ReactClickHandler;
15
+ onContextMenu: ReactClickHandler;
16
+ };
17
+ };
18
+ export declare class IntlManagerReact<DefaultElements extends RichTextElementMap> extends IntlManager<DefaultElements, ReactFunctionTypes> {
5
19
  /**
6
20
  * A reactive component form of `intl.format` that automatically updates when
7
21
  * the application's locale changes and when new data is loaded for the
8
22
  * subject message.
9
23
  */
10
- IntlMessage: <T extends TypedIntlMessageGetter<object | undefined>>(props: {
24
+ IntlMessage: <T extends IntlMessageGetter>(props: {
11
25
  message: T;
12
- values?: Omit<FormatValuesFor<T>, DefaultValues> | never;
26
+ values?: RequiredFormatValues<T, DefaultElements, ReactFunctionTypes> | never;
13
27
  }) => string | React.FunctionComponentElement<{
14
28
  children?: React.ReactNode | undefined;
15
29
  }>;
@@ -18,6 +32,7 @@ export declare class IntlManagerReact<DefaultElements extends RichTextElementMap
18
32
  * and respond to updates about the current locale and other relevant
19
33
  * information.
20
34
  */
21
- format<T extends TypedIntlMessageGetter<object | undefined>>(message: T): React.ReactElement;
22
- format<T extends TypedIntlMessageGetter<object | undefined>>(message: T, values: Omit<FormatValuesFor<T>, DefaultValues>): React.ReactElement;
35
+ format<T extends TypedIntlMessageGetter<undefined>>(message: T): React.ReactElement;
36
+ format<T extends IntlMessageGetter>(message: T, values: RequiredFormatValues<T, DefaultElements, ReactFunctionTypes>): React.ReactElement;
23
37
  }
38
+ export {};
package/dist/react.js CHANGED
@@ -57,7 +57,7 @@ var IntlManagerReact = /** @class */ (function (_super) {
57
57
  // Use the locale from this point in the application, which may be
58
58
  // different from the global locale.
59
59
  var locale = React.useSyncExternalStore(_this.onLocaleChange, function () { return _this.currentLocale; });
60
- // TODO(faulty): This can and should be replaced by a
60
+ // TODO(faulty): This can and should be replaced with
61
61
  // `use(messagesLoadedPromise)` once `use` is shipped to stable.
62
62
  // Source the actual message to render for that locale from its loader.
63
63
  React.useSyncExternalStore(message.onChange, function () { return message(locale); });
package/dist/types.d.ts CHANGED
@@ -19,6 +19,28 @@ export declare function defineMessages(messages: object): object;
19
19
  */
20
20
  export type RichTextElementMap = Record<string, (chunks: any[]) => any>;
21
21
 
22
+ /**
23
+ * Placeholder type representing that this property is a Hook to be rendered
24
+ * while formatting.
25
+ */
26
+ export type HookFunction = { __brand: 'hook-function' };
27
+ /**
28
+ * Placeholder type representing that this property is a Link to be rendered
29
+ * while formatting.
30
+ */
31
+ export type LinkFunction = { __brand: 'link-function' };
32
+ /**
33
+ * Placeholder type representing that this property is a Handler function,
34
+ * which will be attached to some event when the message is formatted.
35
+ */
36
+ export type HandlerFunction = { __brand: 'handler-function' };
37
+
38
+ export type IntlNumber = string | number | null | undefined;
39
+ export type IntlPlural = string | number | null | undefined;
40
+ export type IntlAny = any;
41
+ export type IntlDate = string | number | Date | null | undefined;
42
+ export type IntlTime = string | number | Date | null | undefined;
43
+
22
44
  /**
23
45
  * Messages with no rich formatting and no value interpolations are just plain
24
46
  * strings, and are able to skip multiple formatting steps to work more
@@ -32,12 +54,16 @@ type PlainIntlMessage = string;
32
54
  * the message should be typed as a `PlainIntlMessage`.
33
55
  */
34
56
  type TypedIntlMessage<FormatValues extends object> = InternalIntlMessage & {
35
- values: FormatValues;
57
+ __values: FormatValues;
36
58
  };
37
59
 
38
60
  type AnyIntlMessage<FormatValues extends object | undefined = undefined> =
39
61
  FormatValues extends undefined ? PlainIntlMessage : TypedIntlMessage<FormatValues>;
40
62
 
63
+ export interface IntlMessageGetter extends IntlMessageGetterAdditions {
64
+ (locale: string): InternalIntlMessage | PlainIntlMessage;
65
+ }
66
+
41
67
  /**
42
68
  * Getter function that retrieves a message that best matches the requested
43
69
  * locale. `FormatValues` represents either an object containing all of the
@@ -47,7 +73,50 @@ type AnyIntlMessage<FormatValues extends object | undefined = undefined> =
47
73
  */
48
74
  export interface TypedIntlMessageGetter<FormatValues extends object | undefined>
49
75
  extends IntlMessageGetterAdditions {
50
- (locale: string): AnyIntlMessage<FormatValues>;
76
+ // TODO: This is lossy and unfortunate that typing can't be propagated
77
+ // to the returned message type, but doing so causes problems with
78
+ // contravariance of the type. When returning a message getter from a
79
+ // function with a generic message getter return type, TypeScript will
80
+ // try to assert that every returned type is strictly compatible with the
81
+ // declared return type. In that case, if a message is missing any of the
82
+ // properties declared in the type, it will error. But in the other direction
83
+ // when passing a message getter as a parameter _into_ a function, TypeScript
84
+ // will do the opposite and error if the getter has any _extra_ properties
85
+ // from the declared type. The latter of these is definitely semantically
86
+ // correct, but the former is more just an annoying part of the type system
87
+ // because of this returned type being nested within this interface and the
88
+ // strict subtying that TypeScript uses.
89
+ (locale: string): InternalIntlMessage | PlainIntlMessage;
90
+
91
+ /**
92
+ * A phantom type (not part of the actual typing of the object) that ensures
93
+ * TypeScript won't use subtyping to widen the types of multiple messages
94
+ * unless they are _identical_. For an example of the problem:
95
+ * ```typescript
96
+ * declare const message1: {a: string};
97
+ * declare const message2: {a: string, b: boolean};
98
+ *
99
+ * const foo = condition ? message1 : message2;
100
+ * // ^ This becomes just `{a: string}`, because it's the common base type.
101
+ * ```
102
+ *
103
+ * This is fine for representing the object itself, but on messages those
104
+ * types represent required values at runtime, and so the greatest outer
105
+ * type is the only valid answer, rather than the greatest subtype.
106
+ *
107
+ * This phantom property ensures that TypeScript will never consider two
108
+ * messages subtypes of each other unless they contain identical properties,
109
+ * even the values of those properties, despite the fact that this only
110
+ * considers names being passed in (not sure how, but it does). The result
111
+ * is then always a union type of the possible types, which further
112
+ * downstream will ensure that all format values are always checked for all
113
+ * possible cases of the message.
114
+ */
115
+ __phantom: object extends FormatValues
116
+ ? any
117
+ : FormatValues extends undefined
118
+ ? any
119
+ : keyof FormatValues;
51
120
  }
52
121
 
53
122
  /**
@@ -122,16 +191,109 @@ type FormatValuesFor<T> =
122
191
  // be supplied, represented by the `never`. This prevents accidentally
123
192
  // passing an object, even an empty one, for those strings, and allows the
124
193
  // formatter to optimize a little more on each call.
125
- [T] extends [TypedIntlMessageGetter<undefined>]
194
+ [T] extends [undefined]
126
195
  ? never
127
196
  : // The `never` condition needs to be repeated here, but this time _without_
128
197
  // the `[]` syntax, so that only the union elements with actual values are
129
198
  // included in the union. Without this, the `infer U` on the latter side
130
199
  // fails to resolve and the type just becomes `never`.
131
- UnionToIntersection<
132
- T extends TypedIntlMessageGetter<undefined>
133
- ? never
134
- : T extends TypedIntlMessageGetter<infer U>
135
- ? U
136
- : never
200
+ UnionToIntersection<T extends undefined ? never : T>;
201
+
202
+ /**
203
+ * A template type for replaceable placeholder types to be defined by formatter
204
+ * implementations. Messages are typed using placeholder types like
205
+ * `HookFunction` and `HandlerFunction`, which are empty branded types
206
+ * indicating the _intent_ of a property, which the formatter is then able to
207
+ * replace with a more appropriate type. For example, a React formatter might
208
+ * replace a `HookFunction` with `(content: ReactNode) => ReactNode` to allow
209
+ * the caller to inject React elements within a string, while an AST formatter
210
+ * might require a specific object shape, or allow any plain object.
211
+ */
212
+ interface FunctionTypeMap {
213
+ link: any;
214
+ hook: any;
215
+ handler: any;
216
+ }
217
+
218
+ type ReplaceKeepingNullability<T, P> = P | Exclude<T, NonNullable<T>>;
219
+
220
+ /**
221
+ * Type implementation for applying a `FunctionTypeMap` to a given argument
222
+ * type `T`. For each property of `T`, if its value is one of the placeholder
223
+ * types, like `HookFunction`, it will be replaced by the corresponding
224
+ * template value from `FunctionTypes`.
225
+ */
226
+ type MapFunctionTypes<T, FunctionTypes extends FunctionTypeMap> = {
227
+ [K in keyof T]: ReplaceKeepingNullability<
228
+ T[K],
229
+ NonNullable<T[K]> extends LinkFunction
230
+ ? FunctionTypes['link']
231
+ : NonNullable<T[K]> extends HookFunction
232
+ ? FunctionTypes['hook']
233
+ : NonNullable<T[K]> extends HandlerFunction
234
+ ? FunctionTypes['handler']
235
+ : T[K]
236
+ >;
237
+ };
238
+
239
+ // Detect if T is explicitly `any`, in which case we don't want to do
240
+ // any replacements, since each type should be `any`.
241
+ // https://stackoverflow.com/a/61625831
242
+ type CheckStrictAny<Check, Yes, No> = (Check extends never ? true : false) extends false ? No : Yes;
243
+
244
+ /**
245
+ * Given a `TypedIntlMessageGetter` type, return the format values type
246
+ * argument from it. For example:
247
+ *
248
+ * ```typescript
249
+ * type SomeMessage = TypedIntlMessageGetter<{name: string}>
250
+ * type Result = IntlMessageGetterInnerType<SomeMessage>
251
+ * // Result is `{name: string}`
252
+ * ```
253
+ */
254
+ type IntlMessageGetterInnerType<T extends IntlMessageGetter> =
255
+ T extends TypedIntlMessageGetter<infer U> ? U : never;
256
+
257
+ /**
258
+ * Create the format values argument type for a format function using the
259
+ * configured `DefaultElements` for a formatter and injecting the placeholder
260
+ * types from `FunctionTypes`. The result is the set of required values
261
+ * necessary to format the message of type `T` with the calling formatter.
262
+ */
263
+ type RequiredFormatValues<
264
+ Values extends IntlMessageGetter,
265
+ DefaultElements,
266
+ FunctionTypes extends FunctionTypeMap,
267
+ > =
268
+ IntlMessageGetterInnerType<Values> extends undefined
269
+ ? never
270
+ : CheckStrictAny<
271
+ IntlMessageGetterInnerType<Values>,
272
+ // If `T` is explicitly `any`, then individual properties can't be typed and
273
+ // anything is allowed. This is only typical when consumers manually create a
274
+ // `TypedIntlMessageGetter` type as needed to pass values around, but loses
275
+ // any real type safety for the formatted values.
276
+ // Without this, though, `MapFunctionTypes` would always match the first
277
+ // value and turn every property into the mapped `LinkFunction` type, which
278
+ // is almost always going to be wrong.
279
+ any,
280
+ MapFunctionTypes<
281
+ Omit<FormatValuesFor<IntlMessageGetterInnerType<Values>>, keyof DefaultElements>,
282
+ FunctionTypes
283
+ >
137
284
  >;
285
+
286
+ export interface IntlOptionalRichTextKeys {
287
+ $_: any;
288
+ $b: any;
289
+ $i: any;
290
+ $p: any;
291
+ $link: any;
292
+ $code: any;
293
+ }
294
+
295
+ export type IntlFormatFunctionValuesArg<T extends IntlMessageGetter> = RequiredFormatValues<
296
+ T,
297
+ IntlOptionalRichTextKeys,
298
+ FunctionTypeMap
299
+ >;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@discord/intl",
3
- "version": "0.1.0-rc.1",
3
+ "version": "0.1.2",
4
4
  "license": "MIT",
5
5
  "description": "Client runtime for managing messages and translations in a React project.",
6
6
  "main": "./dist/index.js",
package/src/index.ts CHANGED
@@ -1,4 +1,36 @@
1
+ import { IntlManager } from './intl-manager';
2
+ import { IntlManagerReact } from './react';
3
+
1
4
  export { IntlManager, createLoader, DEFAULT_LOCALE } from './intl-manager';
2
5
  export { IntlManagerReact } from './react';
3
6
 
4
7
  export type * from './types.d.ts';
8
+
9
+ /**
10
+ * A type representing any message that has been formatted using one of the
11
+ * default formatting methods in the `@discord/intl` system. Use this type
12
+ * when you don't care about whether a message is static or contains rich text
13
+ * or has dynamically-formatted values, when you need to handle both in tandem,
14
+ * or when you need a generic return type for a function returning "any
15
+ * any-already formatted message".
16
+ *
17
+ * Generally, this is like `React.ReactNode`, accepting both strings and rich
18
+ * `ReactElement`s, but it is more specific to disallow nullish values and
19
+ * other special React elements like `ReactPortal`. Seeing this type, you can
20
+ * be confident that the value is intended to be the result of formatting an
21
+ * intl message, even if the actual value comes from elsewhere (like a
22
+ * user-generated string).
23
+ */
24
+ export type ReactIntlMessage = string | ReturnType<IntlManagerReact<any>['format']>;
25
+
26
+ /**
27
+ * The return value of `formatToParts` from `@discord/intl`, this type
28
+ * represents any AST structure for a message rendered using this system.
29
+ * The AST generally follows a Markdown-like structure, with text nodes
30
+ * interspersed within and around rich text formatting nodes.
31
+ *
32
+ * ASTs are created _after_ all placeholders have been filled by values
33
+ * from a call to `formatToParts`, meaning they are intended to be fully static
34
+ * structures passed around for custom rendering functions to use.
35
+ */
36
+ export type IntlMessageAst = ReturnType<IntlManager<any, any>['formatToParts']>;
@@ -8,7 +8,23 @@ import {
8
8
 
9
9
  import { LocaleId, LocaleImportMap, MessageLoader } from './message-loader';
10
10
 
11
- import type { FormatValuesFor, RichTextElementMap, TypedIntlMessageGetter } from './types';
11
+ import type {
12
+ FunctionTypeMap,
13
+ IntlMessageGetter,
14
+ RequiredFormatValues,
15
+ RichTextElementMap,
16
+ TypedIntlMessageGetter,
17
+ } from './types';
18
+
19
+ /**
20
+ * Types for formatting functions when calling `formatToParts`, ensuring the
21
+ * functions yield value AST nodes.
22
+ */
23
+ type AstFunctionTypes = {
24
+ link: undefined | object | ((children: object) => Array<string | any>);
25
+ hook: undefined | object | ((children: object) => Array<string | any>);
26
+ handler: undefined | object | ((children: object) => Array<string | any>);
27
+ };
12
28
 
13
29
  /**
14
30
  * Fallback locale used for all internationalization when an operation in the
@@ -18,7 +34,7 @@ export const DEFAULT_LOCALE: string = 'en-US';
18
34
 
19
35
  export class IntlManager<
20
36
  DefaultElements extends RichTextElementMap,
21
- DefaultValues extends keyof DefaultElements,
37
+ FunctionTypes extends FunctionTypeMap,
22
38
  > {
23
39
  defaultLocale: string;
24
40
  currentLocale: string;
@@ -84,16 +100,14 @@ export class IntlManager<
84
100
  * use `format`, which will wrap the formatting in a React component that
85
101
  * subscribes to the current locale and state of loaded messages.
86
102
  */
87
- formatToParts<T extends TypedIntlMessageGetter<object | undefined>>(
88
- message: T,
89
- ): Array<string | any>;
90
- formatToParts<T extends TypedIntlMessageGetter<object | undefined>>(
103
+ formatToParts<T extends TypedIntlMessageGetter<undefined>>(message: T): Array<string | any>;
104
+ formatToParts<T extends IntlMessageGetter>(
91
105
  message: T,
92
- values: Omit<FormatValuesFor<T>, DefaultValues>,
106
+ values: RequiredFormatValues<T, DefaultElements, AstFunctionTypes>,
93
107
  ): Array<string | any>;
94
- formatToParts<T extends TypedIntlMessageGetter<object | undefined>>(
108
+ formatToParts<T extends IntlMessageGetter>(
95
109
  message: T,
96
- values?: Omit<FormatValuesFor<T>, DefaultValues>,
110
+ values?: RequiredFormatValues<T, DefaultElements, AstFunctionTypes>,
97
111
  ): Array<string | any> {
98
112
  if (typeof message === 'string') return [message];
99
113
  const resolvedMessage = typeof message === 'function' ? message(this.currentLocale) : message;
@@ -147,15 +161,15 @@ export class IntlManager<
147
161
  * Format the given message with the provided values, removing any styling
148
162
  * and non-textual content from the message, returning a plain string.
149
163
  */
150
- formatToPlainString<T extends TypedIntlMessageGetter<object | undefined>>(message: T): string;
151
- formatToPlainString<T extends TypedIntlMessageGetter<object | undefined>>(
164
+ formatToPlainString<T extends TypedIntlMessageGetter<undefined>>(message: T): string;
165
+ formatToPlainString<T extends IntlMessageGetter>(
152
166
  message: T,
153
- values: Omit<FormatValuesFor<T>, DefaultValues>,
167
+ values: RequiredFormatValues<T, DefaultElements, FunctionTypes>,
154
168
  ): string;
155
- formatToPlainString<T extends TypedIntlMessageGetter<object | undefined>>(
169
+ formatToPlainString<T extends IntlMessageGetter>(
156
170
  message: T,
157
- values?: Omit<FormatValuesFor<T>, DefaultValues>,
158
- ) {
171
+ values?: RequiredFormatValues<T, DefaultElements, FunctionTypes>,
172
+ ): string {
159
173
  if (typeof message === 'string') return message;
160
174
  const resolvedMessage = message(this.currentLocale);
161
175
  if (typeof resolvedMessage === 'string') return resolvedMessage;
@@ -176,15 +190,15 @@ export class IntlManager<
176
190
  * can be sent through a separate Markdown renderer to get an equivalent
177
191
  * result to formatting this message directly.
178
192
  */
179
- formatToMarkdownString<T extends TypedIntlMessageGetter<object | undefined>>(message: T): string;
180
- formatToMarkdownString<T extends TypedIntlMessageGetter<object | undefined>>(
193
+ formatToMarkdownString<T extends TypedIntlMessageGetter<undefined>>(message: T): string;
194
+ formatToMarkdownString<T extends IntlMessageGetter>(
181
195
  message: T,
182
- values: Omit<FormatValuesFor<T>, DefaultValues>,
196
+ values: RequiredFormatValues<T, DefaultElements, FunctionTypes>,
183
197
  ): string;
184
- formatToMarkdownString<T extends TypedIntlMessageGetter<object | undefined>>(
198
+ formatToMarkdownString<T extends IntlMessageGetter>(
185
199
  message: T,
186
- values?: Omit<FormatValuesFor<T>, DefaultValues>,
187
- ) {
200
+ values?: RequiredFormatValues<T, DefaultElements, FunctionTypes>,
201
+ ): string {
188
202
  // TODO(faulty): Implement the markdown syntax conversion here.
189
203
  if (typeof message === 'string') return message;
190
204
  const resolvedMessage = message(this.currentLocale);
package/src/react.ts CHANGED
@@ -2,26 +2,53 @@ import * as React from 'react';
2
2
 
3
3
  import { IntlManager } from './intl-manager';
4
4
 
5
- import type { FormatValuesFor, RichTextElementMap, TypedIntlMessageGetter } from './types';
5
+ import type {
6
+ IntlMessageGetter,
7
+ RequiredFormatValues,
8
+ RichTextElementMap,
9
+ TypedIntlMessageGetter,
10
+ } from './types';
6
11
 
7
- export class IntlManagerReact<
8
- DefaultElements extends RichTextElementMap,
9
- DefaultValues extends keyof DefaultElements,
10
- > extends IntlManager<DefaultElements, DefaultValues> {
12
+ type ReactHandlerEvent = React.MouseEvent | React.KeyboardEvent;
13
+ type ReactClickHandler = (e: ReactHandlerEvent) => void;
14
+
15
+ type ReactFunctionTypes = {
16
+ // TODO: Allowing `undefined` on these is a migration artifact that should be
17
+ // removed. Functions, like all other values, should be required when
18
+ // formatting to avoid unexpected missing behavior.
19
+ link:
20
+ | undefined
21
+ | ((content: React.ReactNode | React.ReactNode[], key: string) => React.ReactNode);
22
+
23
+ hook:
24
+ | undefined
25
+ | ((content: React.ReactNode | React.ReactNode[], key: string) => React.ReactNode);
26
+ handler:
27
+ | undefined
28
+ | ReactClickHandler
29
+ | { onClick: ReactClickHandler }
30
+ | { onContextMenu: ReactClickHandler }
31
+ | { onClick: ReactClickHandler; onContextMenu: ReactClickHandler };
32
+ };
33
+
34
+ export class IntlManagerReact<DefaultElements extends RichTextElementMap> extends IntlManager<
35
+ DefaultElements,
36
+ ReactFunctionTypes
37
+ > {
11
38
  /**
12
39
  * A reactive component form of `intl.format` that automatically updates when
13
40
  * the application's locale changes and when new data is loaded for the
14
41
  * subject message.
15
42
  */
16
- IntlMessage = <T extends TypedIntlMessageGetter<object | undefined>>(props: {
43
+ IntlMessage = <T extends IntlMessageGetter>(props: {
17
44
  message: T;
18
- values?: Omit<FormatValuesFor<T>, DefaultValues> | never;
45
+ values?: RequiredFormatValues<T, DefaultElements, ReactFunctionTypes> | never;
19
46
  }) => {
20
47
  const { message, values } = props;
21
48
  // Use the locale from this point in the application, which may be
22
49
  // different from the global locale.
23
50
  const locale = React.useSyncExternalStore(this.onLocaleChange, () => this.currentLocale);
24
- // TODO(faulty): This can and should be replaced by a
51
+ // TODO(faulty): This can and should be replaced with
25
52
  // `use(messagesLoadedPromise)` once `use` is shipped to stable.
26
53
  // Source the actual message to render for that locale from its loader.
27
54
  React.useSyncExternalStore(message.onChange, () => message(locale));
@@ -29,13 +56,9 @@ export class IntlManagerReact<
29
56
  // a plain string.
30
57
  if (typeof message === 'string') return message;
31
58
 
32
- const parts = this.formatToParts(message, values as Omit<FormatValuesFor<T>, DefaultValues>);
59
+ const parts = this.formatToParts(message, values);
33
60
  if (parts.length === 1 && typeof parts[0] === 'string') return parts[0];
34
- return React.createElement(
35
- React.Fragment,
36
- undefined,
37
- this.formatToParts(message, values as Omit<FormatValuesFor<T>, DefaultValues>),
38
- );
61
+ return React.createElement(React.Fragment, undefined, this.formatToParts(message, values));
39
62
  };
40
63
 
41
64
  /**
@@ -43,14 +66,14 @@ export class IntlManagerReact<
43
66
  * and respond to updates about the current locale and other relevant
44
67
  * information.
45
68
  */
46
- format<T extends TypedIntlMessageGetter<object | undefined>>(message: T): React.ReactElement;
47
- format<T extends TypedIntlMessageGetter<object | undefined>>(
69
+ format<T extends TypedIntlMessageGetter<undefined>>(message: T): React.ReactElement;
70
+ format<T extends IntlMessageGetter>(
48
71
  message: T,
49
- values: Omit<FormatValuesFor<T>, DefaultValues>,
72
+ values: RequiredFormatValues<T, DefaultElements, ReactFunctionTypes>,
50
73
  ): React.ReactElement;
51
- format<T extends TypedIntlMessageGetter<object | undefined>>(
74
+ format<T extends IntlMessageGetter>(
52
75
  message: T,
53
- values?: Omit<FormatValuesFor<T>, DefaultValues>,
76
+ values?: RequiredFormatValues<T, DefaultElements, ReactFunctionTypes>,
54
77
  ): React.ReactElement {
55
78
  return React.createElement(this.IntlMessage<T>, { message, values });
56
79
  }
package/src/types.d.ts CHANGED
@@ -19,6 +19,28 @@ export declare function defineMessages(messages: object): object;
19
19
  */
20
20
  export type RichTextElementMap = Record<string, (chunks: any[]) => any>;
21
21
 
22
+ /**
23
+ * Placeholder type representing that this property is a Hook to be rendered
24
+ * while formatting.
25
+ */
26
+ export type HookFunction = { __brand: 'hook-function' };
27
+ /**
28
+ * Placeholder type representing that this property is a Link to be rendered
29
+ * while formatting.
30
+ */
31
+ export type LinkFunction = { __brand: 'link-function' };
32
+ /**
33
+ * Placeholder type representing that this property is a Handler function,
34
+ * which will be attached to some event when the message is formatted.
35
+ */
36
+ export type HandlerFunction = { __brand: 'handler-function' };
37
+
38
+ export type IntlNumber = string | number | null | undefined;
39
+ export type IntlPlural = string | number | null | undefined;
40
+ export type IntlAny = any;
41
+ export type IntlDate = string | number | Date | null | undefined;
42
+ export type IntlTime = string | number | Date | null | undefined;
43
+
22
44
  /**
23
45
  * Messages with no rich formatting and no value interpolations are just plain
24
46
  * strings, and are able to skip multiple formatting steps to work more
@@ -32,12 +54,16 @@ type PlainIntlMessage = string;
32
54
  * the message should be typed as a `PlainIntlMessage`.
33
55
  */
34
56
  type TypedIntlMessage<FormatValues extends object> = InternalIntlMessage & {
35
- values: FormatValues;
57
+ __values: FormatValues;
36
58
  };
37
59
 
38
60
  type AnyIntlMessage<FormatValues extends object | undefined = undefined> =
39
61
  FormatValues extends undefined ? PlainIntlMessage : TypedIntlMessage<FormatValues>;
40
62
 
63
+ export interface IntlMessageGetter extends IntlMessageGetterAdditions {
64
+ (locale: string): InternalIntlMessage | PlainIntlMessage;
65
+ }
66
+
41
67
  /**
42
68
  * Getter function that retrieves a message that best matches the requested
43
69
  * locale. `FormatValues` represents either an object containing all of the
@@ -47,7 +73,50 @@ type AnyIntlMessage<FormatValues extends object | undefined = undefined> =
47
73
  */
48
74
  export interface TypedIntlMessageGetter<FormatValues extends object | undefined>
49
75
  extends IntlMessageGetterAdditions {
50
- (locale: string): AnyIntlMessage<FormatValues>;
76
+ // TODO: This is lossy and unfortunate that typing can't be propagated
77
+ // to the returned message type, but doing so causes problems with
78
+ // contravariance of the type. When returning a message getter from a
79
+ // function with a generic message getter return type, TypeScript will
80
+ // try to assert that every returned type is strictly compatible with the
81
+ // declared return type. In that case, if a message is missing any of the
82
+ // properties declared in the type, it will error. But in the other direction
83
+ // when passing a message getter as a parameter _into_ a function, TypeScript
84
+ // will do the opposite and error if the getter has any _extra_ properties
85
+ // from the declared type. The latter of these is definitely semantically
86
+ // correct, but the former is more just an annoying part of the type system
87
+ // because of this returned type being nested within this interface and the
88
+ // strict subtying that TypeScript uses.
89
+ (locale: string): InternalIntlMessage | PlainIntlMessage;
90
+
91
+ /**
92
+ * A phantom type (not part of the actual typing of the object) that ensures
93
+ * TypeScript won't use subtyping to widen the types of multiple messages
94
+ * unless they are _identical_. For an example of the problem:
95
+ * ```typescript
96
+ * declare const message1: {a: string};
97
+ * declare const message2: {a: string, b: boolean};
98
+ *
99
+ * const foo = condition ? message1 : message2;
100
+ * // ^ This becomes just `{a: string}`, because it's the common base type.
101
+ * ```
102
+ *
103
+ * This is fine for representing the object itself, but on messages those
104
+ * types represent required values at runtime, and so the greatest outer
105
+ * type is the only valid answer, rather than the greatest subtype.
106
+ *
107
+ * This phantom property ensures that TypeScript will never consider two
108
+ * messages subtypes of each other unless they contain identical properties,
109
+ * even the values of those properties, despite the fact that this only
110
+ * considers names being passed in (not sure how, but it does). The result
111
+ * is then always a union type of the possible types, which further
112
+ * downstream will ensure that all format values are always checked for all
113
+ * possible cases of the message.
114
+ */
115
+ __phantom: object extends FormatValues
116
+ ? any
117
+ : FormatValues extends undefined
118
+ ? any
119
+ : keyof FormatValues;
51
120
  }
52
121
 
53
122
  /**
@@ -122,16 +191,109 @@ type FormatValuesFor<T> =
122
191
  // be supplied, represented by the `never`. This prevents accidentally
123
192
  // passing an object, even an empty one, for those strings, and allows the
124
193
  // formatter to optimize a little more on each call.
125
- [T] extends [TypedIntlMessageGetter<undefined>]
194
+ [T] extends [undefined]
126
195
  ? never
127
196
  : // The `never` condition needs to be repeated here, but this time _without_
128
197
  // the `[]` syntax, so that only the union elements with actual values are
129
198
  // included in the union. Without this, the `infer U` on the latter side
130
199
  // fails to resolve and the type just becomes `never`.
131
- UnionToIntersection<
132
- T extends TypedIntlMessageGetter<undefined>
133
- ? never
134
- : T extends TypedIntlMessageGetter<infer U>
135
- ? U
136
- : never
200
+ UnionToIntersection<T extends undefined ? never : T>;
201
+
202
+ /**
203
+ * A template type for replaceable placeholder types to be defined by formatter
204
+ * implementations. Messages are typed using placeholder types like
205
+ * `HookFunction` and `HandlerFunction`, which are empty branded types
206
+ * indicating the _intent_ of a property, which the formatter is then able to
207
+ * replace with a more appropriate type. For example, a React formatter might
208
+ * replace a `HookFunction` with `(content: ReactNode) => ReactNode` to allow
209
+ * the caller to inject React elements within a string, while an AST formatter
210
+ * might require a specific object shape, or allow any plain object.
211
+ */
212
+ interface FunctionTypeMap {
213
+ link: any;
214
+ hook: any;
215
+ handler: any;
216
+ }
217
+
218
+ type ReplaceKeepingNullability<T, P> = P | Exclude<T, NonNullable<T>>;
219
+
220
+ /**
221
+ * Type implementation for applying a `FunctionTypeMap` to a given argument
222
+ * type `T`. For each property of `T`, if its value is one of the placeholder
223
+ * types, like `HookFunction`, it will be replaced by the corresponding
224
+ * template value from `FunctionTypes`.
225
+ */
226
+ type MapFunctionTypes<T, FunctionTypes extends FunctionTypeMap> = {
227
+ [K in keyof T]: ReplaceKeepingNullability<
228
+ T[K],
229
+ NonNullable<T[K]> extends LinkFunction
230
+ ? FunctionTypes['link']
231
+ : NonNullable<T[K]> extends HookFunction
232
+ ? FunctionTypes['hook']
233
+ : NonNullable<T[K]> extends HandlerFunction
234
+ ? FunctionTypes['handler']
235
+ : T[K]
236
+ >;
237
+ };
238
+
239
+ // Detect if T is explicitly `any`, in which case we don't want to do
240
+ // any replacements, since each type should be `any`.
241
+ // https://stackoverflow.com/a/61625831
242
+ type CheckStrictAny<Check, Yes, No> = (Check extends never ? true : false) extends false ? No : Yes;
243
+
244
+ /**
245
+ * Given a `TypedIntlMessageGetter` type, return the format values type
246
+ * argument from it. For example:
247
+ *
248
+ * ```typescript
249
+ * type SomeMessage = TypedIntlMessageGetter<{name: string}>
250
+ * type Result = IntlMessageGetterInnerType<SomeMessage>
251
+ * // Result is `{name: string}`
252
+ * ```
253
+ */
254
+ type IntlMessageGetterInnerType<T extends IntlMessageGetter> =
255
+ T extends TypedIntlMessageGetter<infer U> ? U : never;
256
+
257
+ /**
258
+ * Create the format values argument type for a format function using the
259
+ * configured `DefaultElements` for a formatter and injecting the placeholder
260
+ * types from `FunctionTypes`. The result is the set of required values
261
+ * necessary to format the message of type `T` with the calling formatter.
262
+ */
263
+ type RequiredFormatValues<
264
+ Values extends IntlMessageGetter,
265
+ DefaultElements,
266
+ FunctionTypes extends FunctionTypeMap,
267
+ > =
268
+ IntlMessageGetterInnerType<Values> extends undefined
269
+ ? never
270
+ : CheckStrictAny<
271
+ IntlMessageGetterInnerType<Values>,
272
+ // If `T` is explicitly `any`, then individual properties can't be typed and
273
+ // anything is allowed. This is only typical when consumers manually create a
274
+ // `TypedIntlMessageGetter` type as needed to pass values around, but loses
275
+ // any real type safety for the formatted values.
276
+ // Without this, though, `MapFunctionTypes` would always match the first
277
+ // value and turn every property into the mapped `LinkFunction` type, which
278
+ // is almost always going to be wrong.
279
+ any,
280
+ MapFunctionTypes<
281
+ Omit<FormatValuesFor<IntlMessageGetterInnerType<Values>>, keyof DefaultElements>,
282
+ FunctionTypes
283
+ >
137
284
  >;
285
+
286
+ export interface IntlOptionalRichTextKeys {
287
+ $_: any;
288
+ $b: any;
289
+ $i: any;
290
+ $p: any;
291
+ $link: any;
292
+ $code: any;
293
+ }
294
+
295
+ export type IntlFormatFunctionValuesArg<T extends IntlMessageGetter> = RequiredFormatValues<
296
+ T,
297
+ IntlOptionalRichTextKeys,
298
+ FunctionTypeMap
299
+ >;