@discord/intl 0.1.2 → 0.2.0-canary.641ab2f

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.
@@ -1,30 +1,16 @@
1
1
  import { createIntl, IntlShape } from '@formatjs/intl';
2
- import {
3
- Formats,
4
- Formatters,
5
- IntlMessageFormat,
6
- PART_TYPE as FormatPartType,
7
- } from 'intl-messageformat';
2
+ import { Formats, Formatters, IntlMessageFormat } from 'intl-messageformat';
8
3
 
9
4
  import { LocaleId, LocaleImportMap, MessageLoader } from './message-loader';
10
5
 
11
6
  import type {
12
- FunctionTypeMap,
7
+ FormatterImplementation,
13
8
  IntlMessageGetter,
14
9
  RequiredFormatValues,
15
- RichTextElementMap,
16
10
  TypedIntlMessageGetter,
17
11
  } 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
+ import { InternalIntlMessage } from './message';
13
+ import { bindFormatValues, FormatBuilderConstructor } from './format';
28
14
 
29
15
  /**
30
16
  * Fallback locale used for all internationalization when an operation in the
@@ -32,26 +18,35 @@ type AstFunctionTypes = {
32
18
  */
33
19
  export const DEFAULT_LOCALE: string = 'en-US';
34
20
 
35
- export class IntlManager<
36
- DefaultElements extends RichTextElementMap,
37
- FunctionTypes extends FunctionTypeMap,
38
- > {
21
+ type FormatterReturnType<F extends FormatterImplementation<any, any>> =
22
+ F extends FormatterImplementation<any, infer Return> ? Return : never;
23
+
24
+ type FormatterFunctionTypes<F extends FormatterImplementation<any, any>> =
25
+ F extends FormatterImplementation<infer Functions, any> ? Functions : never;
26
+
27
+ export type FormatFunction<F extends FormatterImplementation<any, any>> = <
28
+ T extends IntlMessageGetter,
29
+ >(
30
+ this: IntlManager,
31
+ message: T,
32
+ values: RequiredFormatValues<T, FormatterFunctionTypes<F>>,
33
+ ) => FormatterReturnType<F>;
34
+
35
+ type ThisWithFormatters<
36
+ This,
37
+ T extends Record<string, FormatterImplementation<any, any>>,
38
+ > = This & {
39
+ [K in keyof T]: FormatFunction<T[K]>;
40
+ };
41
+
42
+ export class IntlManager {
39
43
  defaultLocale: string;
40
44
  currentLocale: string;
41
45
  intl: IntlShape;
42
46
 
43
- /**
44
- * When formatting tag elements like `<b>value</b>`, these functions will
45
- * automatically be used by all messages, unless they are explicitly removed
46
- * for a specific call, either by overriding the value or by calling a
47
- * dedicated method like `formatToPlainString` which will remove all
48
- * rich elements from the string and return only the plain text result.
49
- */
50
- defaultRichTextElements: DefaultElements;
51
-
52
47
  _localeSubscriptions: Set<(locale: string) => void>;
53
48
 
54
- constructor(defaultLocale: string = DEFAULT_LOCALE, defaultRichTextElements: DefaultElements) {
49
+ constructor(defaultLocale: string = DEFAULT_LOCALE) {
55
50
  this.defaultLocale = defaultLocale;
56
51
  this.currentLocale = defaultLocale;
57
52
  this.intl = createIntl({
@@ -60,10 +55,38 @@ export class IntlManager<
60
55
  locale: defaultLocale,
61
56
  });
62
57
 
63
- this.defaultRichTextElements = defaultRichTextElements;
64
58
  this._localeSubscriptions = new Set();
65
59
  }
66
60
 
61
+ /**
62
+ * Add a set of formatter implementations to this manager, making each available as a direct
63
+ * property
64
+ */
65
+ withFormatters<const T extends Record<string, FormatterImplementation<any, any>>>(
66
+ formatters: T,
67
+ ): ThisWithFormatters<this, T> {
68
+ for (const [name, formatter] of Object.entries(formatters)) {
69
+ this[name] = this.makeFormatFunction(formatter);
70
+ }
71
+
72
+ return this as ThisWithFormatters<this, T>;
73
+ }
74
+
75
+ /**
76
+ * Return a new function bound to this manager that uses the given `FormatterImplementation` to
77
+ * format a message after it has been resolved for the current locale and potentially processed in
78
+ * other ways by the manager.
79
+ */
80
+ makeFormatFunction<F extends FormatterImplementation<any, any>>({
81
+ format,
82
+ builder,
83
+ }: F): FormatFunction<FormatterReturnType<F>> {
84
+ const formatter = format.bind(this);
85
+ return (message, values) => {
86
+ return formatter(message(this.currentLocale), values, builder);
87
+ };
88
+ }
89
+
67
90
  /**
68
91
  * Set the locale for this package to use. This is a global change, and all
69
92
  * methods on this object will use this locale for formatting.
@@ -89,140 +112,29 @@ export class IntlManager<
89
112
  };
90
113
 
91
114
  /**
92
- * Format the given message in the current locale with the provided values.
93
- * The returned values is _always_ an Array of parts, even if the message is
94
- * a simple string value.
95
- *
96
- * This function is the basis of how messages are normally formatted, and can
97
- * be used anywhere. However, it is not reactive and only functions on the
98
- * data that is currently loaded and known. For a reactive function that
99
- * automatically updates when the locale changes or when new data is loaded,
100
- * use `format`, which will wrap the formatting in a React component that
101
- * subscribes to the current locale and state of loaded messages.
115
+ * For static messages with no rich text and no dynamic placeholders, use this method to
116
+ * immediately return the plain string value of the message in the current locale.
102
117
  */
103
- formatToParts<T extends TypedIntlMessageGetter<undefined>>(message: T): Array<string | any>;
104
- formatToParts<T extends IntlMessageGetter>(
105
- message: T,
106
- values: RequiredFormatValues<T, DefaultElements, AstFunctionTypes>,
107
- ): Array<string | any>;
108
- formatToParts<T extends IntlMessageGetter>(
109
- message: T,
110
- values?: RequiredFormatValues<T, DefaultElements, AstFunctionTypes>,
111
- ): Array<string | any> {
112
- if (typeof message === 'string') return [message];
113
- const resolvedMessage = typeof message === 'function' ? message(this.currentLocale) : message;
114
- if (typeof resolvedMessage === 'string') return [resolvedMessage];
115
-
116
- const resolvedValues =
117
- values != null
118
- ? { ...this.defaultRichTextElements, ...values }
119
- : this.defaultRichTextElements;
120
- const parts = resolvedMessage.formatToParts(
121
- this.intl.formatters as Formatters,
122
- this.intl.formats as Formats,
123
- resolvedValues,
124
- );
125
-
126
- const result = [];
127
- let inLiteral = false;
128
- for (const part of parts) {
129
- // This condition merges consecutive literal elements (static strings)
130
- // into single parts to reduce the number of nodes in the result. This
131
- // condition will never be true on the first loop, ensuring that the
132
- // array must at least have one entry before it attempts to concatenate
133
- // onto that value again.
134
- if (inLiteral && (inLiteral = part.type === FormatPartType.literal)) {
135
- result[result.length - 1] += part.value;
136
- continue;
137
- }
138
-
139
- inLiteral = part.type === FormatPartType.literal;
140
- result.push(part.value);
141
- }
142
-
143
- return result;
118
+ string<T extends TypedIntlMessageGetter<undefined>>(message: T): string {
119
+ // @ts-expect-error Figure out how to make this typing exact. This
120
+ // currently relies on the generic typing being sound enough to know that
121
+ // the message can only contain a single static text node and no
122
+ // placeholders or rich text.
123
+ return message(this.currentLocale).ast[0].value;
144
124
  }
145
125
 
146
- /**
147
- * For messages with no formatting values (i.e., plain strings, they can skip
148
- * a majority of the processing work and just resolve the message itself for
149
- * the current locale, then return the plain string directly.
150
- *
151
- * This is separate from `formatToPlainString`, which still allows for
152
- * formatting variables and other elements but reduces the output to only
153
- * include textual elements in a plain string.
154
- */
155
- string(message: TypedIntlMessageGetter<undefined>): string {
156
- // TODO(faulty): Optimize this so that plain strings are hotpathed even faster.
157
- return this.formatToPlainString(message);
158
- }
159
-
160
- /**
161
- * Format the given message with the provided values, removing any styling
162
- * and non-textual content from the message, returning a plain string.
163
- */
164
- formatToPlainString<T extends TypedIntlMessageGetter<undefined>>(message: T): string;
165
- formatToPlainString<T extends IntlMessageGetter>(
166
- message: T,
167
- values: RequiredFormatValues<T, DefaultElements, FunctionTypes>,
168
- ): string;
169
- formatToPlainString<T extends IntlMessageGetter>(
170
- message: T,
171
- values?: RequiredFormatValues<T, DefaultElements, FunctionTypes>,
172
- ): string {
173
- if (typeof message === 'string') return message;
174
- const resolvedMessage = message(this.currentLocale);
175
- if (typeof resolvedMessage === 'string') return resolvedMessage;
176
-
177
- // No need to pass in `defaultRichTextElements`, since the stylistic tags
178
- // will be removed from the string anyway.
179
- return resolvedMessage.formatToPlainString(
126
+ bindFormatValues<T>(
127
+ Builder: FormatBuilderConstructor<T>,
128
+ message: InternalIntlMessage,
129
+ values: Record<string, any>,
130
+ ): T[] {
131
+ return bindFormatValues(
132
+ Builder,
133
+ message.ast,
134
+ [this.currentLocale, this.defaultLocale],
180
135
  this.intl.formatters as Formatters,
181
136
  this.intl.formats as Formats,
182
137
  values,
183
138
  );
184
139
  }
185
-
186
- /**
187
- * Similar to `formatToPlainString`, format the given message with the provided
188
- * values, but convert all rich text formatting back to Markdown syntax rather
189
- * than rendering the actual rich content. The result is a plain string that
190
- * can be sent through a separate Markdown renderer to get an equivalent
191
- * result to formatting this message directly.
192
- */
193
- formatToMarkdownString<T extends TypedIntlMessageGetter<undefined>>(message: T): string;
194
- formatToMarkdownString<T extends IntlMessageGetter>(
195
- message: T,
196
- values: RequiredFormatValues<T, DefaultElements, FunctionTypes>,
197
- ): string;
198
- formatToMarkdownString<T extends IntlMessageGetter>(
199
- message: T,
200
- values?: RequiredFormatValues<T, DefaultElements, FunctionTypes>,
201
- ): string {
202
- // TODO(faulty): Implement the markdown syntax conversion here.
203
- if (typeof message === 'string') return message;
204
- const resolvedMessage = message(this.currentLocale);
205
- if (typeof resolvedMessage === 'string') return resolvedMessage;
206
-
207
- // No need to pass in `defaultRichTextElements`, since the stylistic tags
208
- // will be removed from the string anyway.
209
- return resolvedMessage.formatToPlainString(
210
- this.intl.formatters as Formatters,
211
- this.intl.formats as Formats,
212
- values,
213
- );
214
- }
215
- }
216
-
217
- /**
218
- * Create a new MessageLoader, which handles lazily loading messages for
219
- * different locales and sanity checks as needed to provide accessors for each
220
- * message defined in `messageKeys`.
221
- */
222
- export function createLoader(
223
- messageKeys: string[],
224
- localeImportMap: LocaleImportMap,
225
- defaultLocale: LocaleId,
226
- ) {
227
- return new MessageLoader(messageKeys, localeImportMap, defaultLocale);
228
140
  }
@@ -38,6 +38,12 @@ export interface FormatJsTime {
38
38
  style?: string;
39
39
  }
40
40
 
41
+ export interface FormatJsSelect {
42
+ type: FormatJsNodeType.Select;
43
+ value: string;
44
+ options: Record<string, { value: FormatJsNode[] }>;
45
+ }
46
+
41
47
  export type FormatJsPluralType = 'cardinal' | 'ordinal';
42
48
 
43
49
  export interface FormatJsPlural {
@@ -64,6 +70,7 @@ export type FormatJsNode =
64
70
  | FormatJsNumber
65
71
  | FormatJsDate
66
72
  | FormatJsTime
73
+ | FormatJsSelect
67
74
  | FormatJsPlural
68
75
  | FormatJsPound
69
76
  | FormatJsTag;
@@ -1,9 +1,7 @@
1
+ import { FormatJsNode } from './keyless-json';
1
2
  import { InternalIntlMessage } from './message';
2
3
 
3
- import type { MessageFormatElement } from '@formatjs/icu-messageformat-parser';
4
-
5
- type AnyRawMessage = string | MessageFormatElement[];
6
- type MessagesData = Record<string, AnyRawMessage>;
4
+ type MessagesData = Record<string, FormatJsNode[]>;
7
5
  export interface IntlMessageGetterAdditions {
8
6
  onChange(callback: () => void): () => void;
9
7
  }
@@ -34,7 +32,18 @@ export class MessageLoader {
34
32
  * then a request has already been made to load the locale and it is waiting
35
33
  * to resolve.
36
34
  */
37
- _localeLoadingPromises: Record<LocaleId, Promise<{ default: MessagesData }> | undefined>;
35
+ _localeLoadingPromises: Record<
36
+ LocaleId,
37
+ {
38
+ /**`true` if this locale has been loaded at least once. */
39
+ initialized: boolean;
40
+ /**
41
+ * If the locale is currently being loaded, this Promise will exist and
42
+ * represent that loading status. Otherwise, this value is undefined.
43
+ */
44
+ current?: Promise<{ default: MessagesData }> | undefined;
45
+ }
46
+ >;
38
47
 
39
48
  /**
40
49
  * Map of pre-parsed messages, keyed by the message key and locale
@@ -87,7 +96,7 @@ export class MessageLoader {
87
96
  this._subscribers = new Set();
88
97
 
89
98
  this._loadLocale(this.defaultLocale);
90
- this.fallbackMessage = new InternalIntlMessage('THIS MESSAGE FAILED TO LOAD', 'en-US');
99
+ this.fallbackMessage = new InternalIntlMessage([], this.defaultLocale);
91
100
 
92
101
  // In cases where hot module replacement is available, set up cache clearing whenever the
93
102
  // targets change so that values are always replaced.
@@ -95,9 +104,9 @@ export class MessageLoader {
95
104
  if (module.hot) {
96
105
  for (const [locale, file] of Object.entries(localeImportMap)) {
97
106
  // @ts-expect-error `hot` not defined in types.
98
- module.hot.accept(file, () => {
107
+ module.hot.accept(file, async () => {
108
+ await this._loadLocale(locale);
99
109
  this._parseCache.clear();
100
- this._loadLocale(locale);
101
110
  });
102
111
  }
103
112
  }
@@ -118,9 +127,7 @@ export class MessageLoader {
118
127
  get(key: string, locale: LocaleId): InternalIntlMessage {
119
128
  const value =
120
129
  this.getMessageValue(key, locale) ?? this.getMessageValue(key, this.defaultLocale);
121
- if (value != null) {
122
- return value;
123
- }
130
+ if (value != null) return value;
124
131
 
125
132
  // If the message couldn't be found in either the requested nor the default locale, then
126
133
  // nothing can be done.
@@ -151,18 +158,14 @@ export class MessageLoader {
151
158
  const cachedValue = this._parseCache.get(cacheKey);
152
159
  if (cachedValue != null) return cachedValue;
153
160
 
154
- // Return early if this locale is still in the process of being loaded.
155
- if (this._localeLoadingPromises[locale] != null) {
156
- return undefined;
157
- }
158
-
159
- // If not, check whether it's been loaded and trigger a load if not.
161
+ // Check whether the locale exists in memory. If not, start a request to
162
+ // load the locale if one has not already started. It's impossible for
163
+ // the request to finish within the same tick, so return undefined in any
164
+ // case then.
160
165
  if (this.messages[locale] == null) {
161
- // Ensure the locale is loaded, if it is supported
162
166
  if (this.supportedLocales.includes(locale)) {
163
167
  this._loadLocale(locale);
164
168
  }
165
-
166
169
  return undefined;
167
170
  }
168
171
 
@@ -199,7 +202,7 @@ export class MessageLoader {
199
202
 
200
203
  async _loadLocale(locale: LocaleId) {
201
204
  // Don't re-load a locale that's already in progress.
202
- if (this._localeLoadingPromises[locale] != null) {
205
+ if (this._localeLoadingPromises[locale]?.current != null) {
203
206
  return;
204
207
  }
205
208
 
@@ -215,10 +218,10 @@ export class MessageLoader {
215
218
  // If the locale is already set in `messages`, then it doesn't need to be loaded again.
216
219
  if (this.messages[locale] != null) return;
217
220
 
218
- const loadingPromise = this.localeImportMap[locale]();
219
- this._localeLoadingPromises[locale] = loadingPromise;
220
- this.messages[locale] = (await loadingPromise).default;
221
- delete this._localeLoadingPromises[locale];
221
+ const current = this.localeImportMap[locale]();
222
+ this._localeLoadingPromises[locale] = { initialized: false, current };
223
+ this.messages[locale] = (await current).default;
224
+ this._localeLoadingPromises[locale] = { initialized: true, current: undefined };
222
225
  this.emitChange();
223
226
  }
224
227
 
@@ -240,4 +243,68 @@ export class MessageLoader {
240
243
 
241
244
  return () => this._subscribers.delete(callback);
242
245
  }
246
+
247
+ isLocaleLoaded(locale: LocaleId, requireCurrent: boolean = false): boolean {
248
+ const ref = this._localeLoadingPromises[locale];
249
+ // Ensure the locale at least exists and is initialized.
250
+ if (ref == null || ref.initialized == false) return false;
251
+ // `current` will be deleted once the locale has loaded, so if it is null,
252
+ // then the locale data is current.
253
+ if (requireCurrent) return ref.current == null;
254
+ // Otherwise, initialization is enough.
255
+ return true;
256
+ }
257
+
258
+ async waitForLocaleLoaded(locale: LocaleId, requireCurrent = false): Promise<void> {
259
+ const ref = this._localeLoadingPromises[locale];
260
+ // If the locale hasn't been loaded at all, kick off the load and return
261
+ // that Promise directly.
262
+ if (ref == null) return this._loadLocale(locale);
263
+ // If initialization is enough for this request, just resolve immediately.
264
+ if (ref.initialized && !requireCurrent) return;
265
+ // Otherwise, check the `current` loading state on the object, and if that
266
+ // doesn't exist, then the locale data is already finished loading, and the
267
+ // containing Promise will just resolve with `undefined`.
268
+ await ref.current;
269
+ }
270
+
271
+ /**
272
+ * Returns true if this loader's default locale has finished loading. When
273
+ * true, all messages managed by this loader are guaranteed to have _a_
274
+ * render-able value, even if one does not exist in the current locale.
275
+ */
276
+ async waitForDefaultLocale(requireCurrent = false): Promise<void> {
277
+ return this.waitForLocaleLoaded(this.defaultLocale, requireCurrent);
278
+ }
279
+ }
280
+
281
+ const LOADER_REGISTRY: MessageLoader[] = [];
282
+
283
+ /**
284
+ * Returns a new Promise that resolves after all currently-registered message
285
+ * loaders have finished successfully loading their default locale content.
286
+ * Once this Promise has resolved, all messages that currently exist in the
287
+ * application (i.e., within all modules that have been imported or required in
288
+ * the current session) can be guaranteed to have _a_ render-able value.
289
+ *
290
+ * This function can be called at any point in an applications life cycle and
291
+ * will always ensure that _all_ loaders that currently exist are initialized.
292
+ */
293
+ export async function waitForAllDefaultIntlMessagesLoaded(): Promise<void> {
294
+ await Promise.all(LOADER_REGISTRY.map((loader) => loader.waitForDefaultLocale()));
295
+ }
296
+
297
+ /**
298
+ * Create a new MessageLoader, which handles lazily loading messages for
299
+ * different locales and sanity checks as needed to provide accessors for each
300
+ * message defined in `messageKeys`.
301
+ */
302
+ export function createLoader(
303
+ messageKeys: string[],
304
+ localeImportMap: LocaleImportMap,
305
+ defaultLocale: LocaleId,
306
+ ) {
307
+ const loader = new MessageLoader(messageKeys, localeImportMap, defaultLocale);
308
+ LOADER_REGISTRY.push(loader);
309
+ return loader;
243
310
  }
package/src/message.ts CHANGED
@@ -1,103 +1,11 @@
1
- import {
2
- TYPE as FormatElementType,
3
- parse as parseMessageFormat,
4
- } from '@formatjs/icu-messageformat-parser';
5
- import {
6
- Formats,
7
- Formatters,
8
- MessageFormatPart,
9
- formatToParts as libFormatToParts,
10
- } from 'intl-messageformat';
11
-
12
- import type { MessageFormatElement } from '@formatjs/icu-messageformat-parser';
1
+ import { FormatJsNode } from './keyless-json';
13
2
 
14
3
  export class InternalIntlMessage {
15
4
  locale: string;
16
- message?: string;
17
- ast: MessageFormatElement[];
18
- /**
19
- * A stripped-down representation of the message with no rich formatting
20
- * elements preserved. This will be lazily created the first time it is
21
- * requested (e.g., by calling `formatToPlainString`).
22
- */
23
- plainAst?: MessageFormatElement[];
5
+ ast: FormatJsNode[];
24
6
 
25
- constructor(messageOrAst: string | MessageFormatElement[], locale: string) {
7
+ constructor(messageOrAst: FormatJsNode[], locale: string) {
26
8
  this.locale = locale;
27
-
28
- if (typeof messageOrAst === 'string') {
29
- this.message = messageOrAst;
30
- this.ast = parseMessageFormat(messageOrAst);
31
- } else {
32
- this.ast = messageOrAst;
33
- }
34
-
35
- this.plainAst = undefined;
36
- }
37
-
38
- /**
39
- * Format this message with the given locale into an AST of chunks that can
40
- * then be used or rendered into strings, React elements, or anything else as
41
- * needed.
42
- */
43
- formatToParts<T>(
44
- formatters: Formatters,
45
- formats: Formats,
46
- values?: Record<string, any>,
47
- ): Array<MessageFormatPart<T>> {
48
- return libFormatToParts(this.ast, this.locale, formatters, formats, values);
49
- }
50
-
51
- /**
52
- * Formats this message just like `formatToParts`, but strips all stylistic
53
- * elements and tags and returns a plain string with only the literal text of
54
- * the message remaining.
55
- *
56
- * This method works by quickly scanning the string and removing any tag
57
- * elements, formatting, then concatenating the pieces into a single result.
58
- *
59
- * Note that this function does _not_ handle serializing values other than
60
- * plain/primitive values into strings. All values are simply converted using
61
- * the `String()` constructor. So, for example, passing a function as a value
62
- * will directly serialize the function definition into a string, rather than
63
- * calling the function or evaluating the result.
64
- */
65
- formatToPlainString(
66
- formatters: Formatters,
67
- formats: Formats,
68
- values?: Record<string, any>,
69
- ): string {
70
- // Lazily create the reduced AST if it hasn't already been done.
71
- if (this.plainAst == null) {
72
- this.plainAst = [];
73
- for (const part of this.ast) {
74
- this.plainAst.push(...this._removeRichTags(part));
75
- }
76
- }
77
-
78
- const parts = libFormatToParts(this.plainAst, this.locale, formatters, formats, values);
79
- let result = '';
80
- for (const part of parts) {
81
- result += String(part.value);
82
- }
83
-
84
- return result;
85
- }
86
-
87
- /**
88
- * Returns the same element with any formatting tags removed. If the element
89
- * itself is a tag, the children will be hoisted and returned, otherwise the
90
- * element is returned as an array of itself.
91
- */
92
- _removeRichTags(element: MessageFormatElement): MessageFormatElement[] {
93
- if (element.type === FormatElementType.tag) {
94
- const childContent = [];
95
- for (const child of element.children) {
96
- childContent.push(...this._removeRichTags(child));
97
- }
98
- return childContent;
99
- } else {
100
- return [element];
101
- }
9
+ this.ast = messageOrAst;
102
10
  }
103
11
  }