@discord/intl 0.0.1
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/LICENSE +21 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +9 -0
- package/dist/intl-manager.d.ts +59 -0
- package/dist/intl-manager.js +138 -0
- package/dist/keyless-json.d.ts +61 -0
- package/dist/keyless-json.js +107 -0
- package/dist/message-loader.d.ts +60 -0
- package/dist/message-loader.js +168 -0
- package/dist/message.d.ts +41 -0
- package/dist/message.js +148 -0
- package/dist/react.d.ts +24 -0
- package/dist/react.js +80 -0
- package/dist/types.d.ts +158 -0
- package/package.json +43 -0
- package/src/index.ts +4 -0
- package/src/intl-manager.ts +166 -0
- package/src/keyless-json.ts +154 -0
- package/src/message-loader.ts +156 -0
- package/src/message.ts +110 -0
- package/src/react.ts +59 -0
- package/src/types.d.ts +158 -0
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
export enum FormatJsNodeType {
|
|
2
|
+
Literal = 0,
|
|
3
|
+
Argument,
|
|
4
|
+
Number,
|
|
5
|
+
Date,
|
|
6
|
+
Time,
|
|
7
|
+
Select,
|
|
8
|
+
Plural,
|
|
9
|
+
Pound,
|
|
10
|
+
Tag,
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface FormatJsLiteral {
|
|
14
|
+
type: FormatJsNodeType.Literal;
|
|
15
|
+
value: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface FormatJsArgument {
|
|
19
|
+
type: FormatJsNodeType.Argument;
|
|
20
|
+
value: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface FormatJsNumber {
|
|
24
|
+
type: FormatJsNodeType.Number;
|
|
25
|
+
value: string;
|
|
26
|
+
style?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface FormatJsDate {
|
|
30
|
+
type: FormatJsNodeType.Date;
|
|
31
|
+
value: string;
|
|
32
|
+
style?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface FormatJsTime {
|
|
36
|
+
type: FormatJsNodeType.Time;
|
|
37
|
+
value: string;
|
|
38
|
+
style?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export type FormatJsPluralType = 'cardinal' | 'ordinal';
|
|
42
|
+
|
|
43
|
+
export interface FormatJsPlural {
|
|
44
|
+
type: FormatJsNodeType.Plural;
|
|
45
|
+
value: string;
|
|
46
|
+
options: Record<string, { value: FormatJsNode[] }>;
|
|
47
|
+
offset: number;
|
|
48
|
+
pluralType: FormatJsPluralType;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface FormatJsPound {
|
|
52
|
+
type: FormatJsNodeType.Pound;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface FormatJsTag {
|
|
56
|
+
type: FormatJsNodeType.Tag;
|
|
57
|
+
value: string;
|
|
58
|
+
children: FormatJsNode[];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export type FormatJsNode =
|
|
62
|
+
| FormatJsLiteral
|
|
63
|
+
| FormatJsArgument
|
|
64
|
+
| FormatJsNumber
|
|
65
|
+
| FormatJsDate
|
|
66
|
+
| FormatJsTime
|
|
67
|
+
| FormatJsPlural
|
|
68
|
+
| FormatJsPound
|
|
69
|
+
| FormatJsTag;
|
|
70
|
+
|
|
71
|
+
// Using a static object here ensures there's only one allocation for it, which
|
|
72
|
+
// will likely appear in many, many messages. Freezing ensures that the shared
|
|
73
|
+
// object can't be accidentally modified if a consumer tries to transform the
|
|
74
|
+
// AST.
|
|
75
|
+
export const FORMAT_JS_POUND: FormatJsPound = Object.freeze({ type: 7 });
|
|
76
|
+
|
|
77
|
+
function hydrateArray(elements: Array<Array<any>>) {
|
|
78
|
+
for (let i = 0; i < elements.length; i++) {
|
|
79
|
+
elements[i] = hydrateFormatJsAst(elements[i]);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function hydratePlural(keyless: Array<any>): FormatJsNode {
|
|
84
|
+
const [type, value, options, offset, pluralType] = keyless;
|
|
85
|
+
// This tries to be efficient about updating the value for each option
|
|
86
|
+
// with a parsed version, reusing the same receiving object, and even
|
|
87
|
+
// the inner key within it, just replacing the end value with the
|
|
88
|
+
// parsed version.
|
|
89
|
+
// This saves multiple allocations compared to building a new object
|
|
90
|
+
// from scratch, either for the whole options object or for each value.
|
|
91
|
+
for (const key in options) {
|
|
92
|
+
hydrateArray(options[key].value);
|
|
93
|
+
}
|
|
94
|
+
// `pluralType` is technically only valid on `Plural` nodes, even
|
|
95
|
+
// though the structure is identical to `Select`.
|
|
96
|
+
if (type === FormatJsNodeType.Plural) {
|
|
97
|
+
return { type, value, options, offset, pluralType };
|
|
98
|
+
} else {
|
|
99
|
+
return { type, value, options, offset };
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function hydrateSingle(keyless: Array<any>): FormatJsNode {
|
|
104
|
+
const [type] = keyless;
|
|
105
|
+
switch (type) {
|
|
106
|
+
case FormatJsNodeType.Literal:
|
|
107
|
+
case FormatJsNodeType.Argument:
|
|
108
|
+
return { type, value: keyless[1] };
|
|
109
|
+
case FormatJsNodeType.Number:
|
|
110
|
+
case FormatJsNodeType.Date:
|
|
111
|
+
case FormatJsNodeType.Time:
|
|
112
|
+
return { type, value: keyless[1], style: keyless[2] };
|
|
113
|
+
case FormatJsNodeType.Select:
|
|
114
|
+
case FormatJsNodeType.Plural:
|
|
115
|
+
return hydratePlural(keyless);
|
|
116
|
+
case FormatJsNodeType.Pound:
|
|
117
|
+
return FORMAT_JS_POUND;
|
|
118
|
+
case FormatJsNodeType.Tag: {
|
|
119
|
+
const [type, value, children] = keyless;
|
|
120
|
+
hydrateArray(children);
|
|
121
|
+
return { type, value, children: children };
|
|
122
|
+
}
|
|
123
|
+
default:
|
|
124
|
+
throw new Error(`FormatJS keyless JSON encountered an unknown type: ${type}`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Hydrate the given keyless JSON into a FormatJS-compatible AST structure.
|
|
130
|
+
* The given object is reused as much as possible, meaning it cannot be safely
|
|
131
|
+
* used again after hydrating.
|
|
132
|
+
*/
|
|
133
|
+
export function hydrateFormatJsAst(keyless: Array<Array<any>>): FormatJsNode[];
|
|
134
|
+
export function hydrateFormatJsAst(keyless: Array<any>): FormatJsNode | FormatJsNode[] {
|
|
135
|
+
// If the first element of the array is itself an array, then we have a list
|
|
136
|
+
// of elements to parse rather than a single object.
|
|
137
|
+
if (Array.isArray(keyless[0])) {
|
|
138
|
+
hydrateArray(keyless);
|
|
139
|
+
return keyless;
|
|
140
|
+
} else if (keyless.length === 0) {
|
|
141
|
+
// Some entries can be empty arrays, like an empty set of children, and those can
|
|
142
|
+
// be preserved.
|
|
143
|
+
return keyless;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return hydrateSingle(keyless);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function hydrateMessages(messages: Record<string, any>) {
|
|
150
|
+
for (const key in messages) {
|
|
151
|
+
messages[key] = hydrateFormatJsAst(messages[key]);
|
|
152
|
+
}
|
|
153
|
+
return messages;
|
|
154
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { InternalIntlMessage } from './message';
|
|
2
|
+
|
|
3
|
+
import type { MessageFormatElement } from '@formatjs/icu-messageformat-parser';
|
|
4
|
+
|
|
5
|
+
type AnyRawMessage = string | MessageFormatElement[];
|
|
6
|
+
type MessagesData = Record<string, AnyRawMessage>;
|
|
7
|
+
export interface IntlMessageGetterAdditions {
|
|
8
|
+
isLocaleLoaded(locale: LocaleId): boolean;
|
|
9
|
+
onChange(callback: () => void): () => void;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface IntlMessageGetter extends IntlMessageGetterAdditions {
|
|
13
|
+
(locale: LocaleId): InternalIntlMessage;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
type LocaleId = string;
|
|
17
|
+
|
|
18
|
+
export type LocaleImportMap = Record<LocaleId, () => Promise<{ default: MessagesData }>>;
|
|
19
|
+
|
|
20
|
+
export class MessageLoader {
|
|
21
|
+
messageKeys: string[];
|
|
22
|
+
/** Map of locale to string key to message content. */
|
|
23
|
+
messages: Record<LocaleId, MessagesData>;
|
|
24
|
+
localeImportMap: LocaleImportMap;
|
|
25
|
+
supportedLocales: LocaleId[];
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Promise representing the current locale being loaded. If this is non-null,
|
|
29
|
+
* then a request has already been made to load the locale and it is waiting
|
|
30
|
+
* to resolve.
|
|
31
|
+
*/
|
|
32
|
+
_localeLoadingPromises: Record<LocaleId, Promise<{ default: MessagesData }> | undefined>;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Map of pre-parsed messages, keyed by the message key and locale
|
|
36
|
+
*/
|
|
37
|
+
_parseCache: Map<string, InternalIntlMessage>;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* List of subscribers listening for changes to the current locale and
|
|
41
|
+
* when it has loaded.
|
|
42
|
+
*/
|
|
43
|
+
_subscribers: Set<() => void>;
|
|
44
|
+
|
|
45
|
+
fallbackMessage: InternalIntlMessage;
|
|
46
|
+
|
|
47
|
+
constructor(messageKeys: string[], localeImportMap: LocaleImportMap) {
|
|
48
|
+
this.messageKeys = messageKeys;
|
|
49
|
+
this.messages = {};
|
|
50
|
+
this.localeImportMap = localeImportMap;
|
|
51
|
+
this.supportedLocales = Object.keys(localeImportMap);
|
|
52
|
+
|
|
53
|
+
this._localeLoadingPromises = {};
|
|
54
|
+
this._parseCache = new Map();
|
|
55
|
+
this._subscribers = new Set();
|
|
56
|
+
|
|
57
|
+
this.fallbackMessage = new InternalIntlMessage('THIS MESSAGE FAILED TO LOAD', 'en-US');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
get(key: string, locale: LocaleId): InternalIntlMessage {
|
|
61
|
+
const cacheKey = key + '@' + locale;
|
|
62
|
+
const cachedValue = this._parseCache.get(cacheKey);
|
|
63
|
+
if (cachedValue != null) return cachedValue;
|
|
64
|
+
|
|
65
|
+
if (this.messages[locale] == null) {
|
|
66
|
+
// Ensure the locale is loaded, if it is supported
|
|
67
|
+
if (this.supportedLocales.includes(locale)) {
|
|
68
|
+
this._loadLocale(locale);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Otherwise, fallback to just the key value in the meantime.
|
|
72
|
+
return this.fallbackMessage;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Then try to return the loaded message
|
|
76
|
+
if (key in this.messages[locale]) {
|
|
77
|
+
const content = this.messages[locale][key];
|
|
78
|
+
const message = new InternalIntlMessage(content, locale);
|
|
79
|
+
this._parseCache.set(cacheKey, message);
|
|
80
|
+
return message;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// And finally throw if there's no match whatsoever
|
|
84
|
+
throw new Error(
|
|
85
|
+
`Requested message ${key} does not have a value in the requested locale nor the default locale`,
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Returns a record mapping the keys this object manages to bound functions
|
|
91
|
+
* for `get` with the that key as the first argument, allowing consumers to
|
|
92
|
+
* just call the function with a locale to retrieve the translated message
|
|
93
|
+
* for that key.
|
|
94
|
+
*/
|
|
95
|
+
getBinds(): Record<string, IntlMessageGetter> {
|
|
96
|
+
const onChange = this.onChange.bind(this);
|
|
97
|
+
return this.messageKeys.reduce(
|
|
98
|
+
(acc, key) => {
|
|
99
|
+
const bound = this.get.bind(this, key) as IntlMessageGetter;
|
|
100
|
+
bound.onChange = onChange;
|
|
101
|
+
acc[key] = bound;
|
|
102
|
+
return acc;
|
|
103
|
+
},
|
|
104
|
+
{} as Record<string, IntlMessageGetter>,
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async _loadLocale(locale: LocaleId) {
|
|
109
|
+
// Don't re-load a locale that's already in progress.
|
|
110
|
+
if (this._localeLoadingPromises[locale] != null) {
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Safety check in case the locale map doesn't include a require for the
|
|
115
|
+
// requested locale. Shouldn't happen, but throwing here is much more
|
|
116
|
+
// contextual than whatever error would result otherwise.
|
|
117
|
+
if (this.localeImportMap[locale] == null) {
|
|
118
|
+
throw new Error(
|
|
119
|
+
`Requested to load locale ${locale}, which should be supported, but no source for translation data was provided.`,
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// If the locale is already set in `messages`, then it doesn't need to be loaded again.
|
|
124
|
+
if (this.messages[locale] != null) return;
|
|
125
|
+
|
|
126
|
+
const loadStart = performance.now();
|
|
127
|
+
const loadingPromise = this.localeImportMap[locale]();
|
|
128
|
+
this._localeLoadingPromises[locale] = loadingPromise;
|
|
129
|
+
this.messages[locale] = (await loadingPromise).default;
|
|
130
|
+
delete this._localeLoadingPromises[locale];
|
|
131
|
+
const loadEnd = performance.now();
|
|
132
|
+
console.warn(
|
|
133
|
+
`[INTL] Took ${loadEnd - loadStart}ms to load ${locale} with ${Object.keys(this.messages[locale]).length} keys`,
|
|
134
|
+
);
|
|
135
|
+
this.emitChange();
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Inform subscribers that the loader state has changed and they should
|
|
140
|
+
* potentially update to get new values for messages.
|
|
141
|
+
*/
|
|
142
|
+
emitChange() {
|
|
143
|
+
for (const callback of this._subscribers.values()) {
|
|
144
|
+
callback();
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Subscribe to events about when a locale has loaded.
|
|
150
|
+
*/
|
|
151
|
+
onChange(callback: () => void): () => void {
|
|
152
|
+
this._subscribers.add(callback);
|
|
153
|
+
|
|
154
|
+
return () => this._subscribers.delete(callback);
|
|
155
|
+
}
|
|
156
|
+
}
|
package/src/message.ts
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
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';
|
|
13
|
+
|
|
14
|
+
export class InternalIntlMessage {
|
|
15
|
+
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[];
|
|
24
|
+
|
|
25
|
+
constructor(messageOrAst: string | MessageFormatElement[], locale: string) {
|
|
26
|
+
this.locale = locale;
|
|
27
|
+
|
|
28
|
+
if (typeof messageOrAst === 'string') {
|
|
29
|
+
// TODO: This part should pre-parse our Markdown shorthand, too, so that
|
|
30
|
+
// the AST that comes out of this is a standardized format, and we don't
|
|
31
|
+
// have to do any special handling after calling `format`. That also
|
|
32
|
+
// removes the need for handling "unsafe" values, since they'll be
|
|
33
|
+
// parsed before values get interpolated. We can actually do that in the
|
|
34
|
+
// loader, too, and avoid any runtime cost for it.
|
|
35
|
+
|
|
36
|
+
this.message = messageOrAst;
|
|
37
|
+
this.ast = parseMessageFormat(messageOrAst);
|
|
38
|
+
} else {
|
|
39
|
+
this.ast = messageOrAst;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
this.plainAst = undefined;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Format this message with the given locale into an AST of chunks that can
|
|
47
|
+
* then be used or rendered into strings, React elements, or anything else as
|
|
48
|
+
* needed.
|
|
49
|
+
*/
|
|
50
|
+
formatToParts<T>(
|
|
51
|
+
formatters: Formatters,
|
|
52
|
+
formats: Formats,
|
|
53
|
+
values?: Record<string, any>,
|
|
54
|
+
): Array<MessageFormatPart<T>> {
|
|
55
|
+
return libFormatToParts(this.ast, this.locale, formatters, formats, values);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Formats this message just like `formatToParts`, but strips all stylistic
|
|
60
|
+
* elements and tags and returns a plain string with only the literal text of
|
|
61
|
+
* the message remaining.
|
|
62
|
+
*
|
|
63
|
+
* This method works by quickly scanning the string and removing any tag
|
|
64
|
+
* elements, formatting, then concatenating the pieces into a single result.
|
|
65
|
+
*
|
|
66
|
+
* Note that this function does _not_ handle serializing values other than
|
|
67
|
+
* plain/primitive values into strings. All values are simply converted using
|
|
68
|
+
* the `String()` constructor. So, for example, passing a function as a value
|
|
69
|
+
* will directly serialize the function definition into a string, rather than
|
|
70
|
+
* calling the function or evaluating the result.
|
|
71
|
+
*/
|
|
72
|
+
formatToPlainString(
|
|
73
|
+
formatters: Formatters,
|
|
74
|
+
formats: Formats,
|
|
75
|
+
values?: Record<string, any>,
|
|
76
|
+
): string {
|
|
77
|
+
// Lazily create the reduced AST if it hasn't already been done.
|
|
78
|
+
if (this.plainAst == null) {
|
|
79
|
+
this.plainAst = [];
|
|
80
|
+
for (const part of this.ast) {
|
|
81
|
+
this.plainAst.push(...this._removeRichTags(part));
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const parts = libFormatToParts(this.plainAst, this.locale, formatters, formats, values);
|
|
86
|
+
let result = '';
|
|
87
|
+
for (const part of parts) {
|
|
88
|
+
result += String(part.value);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return result;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Returns the same element with any formatting tags removed. If the element
|
|
96
|
+
* itself is a tag, the children will be hoisted and returned, otherwise the
|
|
97
|
+
* element is returned as an array of itself.
|
|
98
|
+
*/
|
|
99
|
+
_removeRichTags(element: MessageFormatElement): MessageFormatElement[] {
|
|
100
|
+
if (element.type === FormatElementType.tag) {
|
|
101
|
+
const childContent = [];
|
|
102
|
+
for (const child of element.children) {
|
|
103
|
+
childContent.push(...this._removeRichTags(child));
|
|
104
|
+
}
|
|
105
|
+
return childContent;
|
|
106
|
+
} else {
|
|
107
|
+
return [element];
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
package/src/react.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
|
|
3
|
+
import { IntlManager } from './intl-manager';
|
|
4
|
+
|
|
5
|
+
import type { FormatValuesFor, RichTextElementMap, TypedIntlMessageGetter } from './types';
|
|
6
|
+
|
|
7
|
+
export class IntlManagerReact<
|
|
8
|
+
DefaultElements extends RichTextElementMap,
|
|
9
|
+
DefaultValues extends keyof DefaultElements,
|
|
10
|
+
> extends IntlManager<DefaultElements, DefaultValues> {
|
|
11
|
+
/**
|
|
12
|
+
* A reactive component form of `intl.format` that automatically updates when
|
|
13
|
+
* the application's locale changes and when new data is loaded for the
|
|
14
|
+
* subject message.
|
|
15
|
+
*/
|
|
16
|
+
IntlMessage = <T extends TypedIntlMessageGetter<object | undefined>>(props: {
|
|
17
|
+
message: T;
|
|
18
|
+
values?: Omit<FormatValuesFor<T>, DefaultValues>;
|
|
19
|
+
}) => {
|
|
20
|
+
const { message, values } = props;
|
|
21
|
+
// Use the locale from this point in the application, which may be
|
|
22
|
+
// different from the global locale.
|
|
23
|
+
const locale = React.useSyncExternalStore(this.onLocaleChange, () => this.currentLocale);
|
|
24
|
+
// Source the actual message to render for that locale from its loader.
|
|
25
|
+
// TODO(faulty): This can and should be replaced by a
|
|
26
|
+
// `use(messagesLoadedPromise)` once `use` is shipped to stable.
|
|
27
|
+
React.useSyncExternalStore(message.onChange, () => message(locale));
|
|
28
|
+
// The return the formatted version of that string.
|
|
29
|
+
if (typeof message === 'string') return message;
|
|
30
|
+
|
|
31
|
+
return React.createElement(
|
|
32
|
+
React.Fragment,
|
|
33
|
+
undefined,
|
|
34
|
+
this.formatToParts(message, values as Omit<FormatValuesFor<T>, DefaultValues> | never),
|
|
35
|
+
);
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Format the given message as a React component, allowing it to listen for
|
|
40
|
+
* and respond to updates about the current locale and other relevant
|
|
41
|
+
* information.
|
|
42
|
+
*/
|
|
43
|
+
format<T extends TypedIntlMessageGetter<object | undefined>>(message: T): JSX.Element;
|
|
44
|
+
format<T extends TypedIntlMessageGetter<object | undefined>>(
|
|
45
|
+
message: T,
|
|
46
|
+
values: Omit<FormatValuesFor<T>, DefaultValues>,
|
|
47
|
+
): JSX.Element;
|
|
48
|
+
format(message: any): JSX.Element;
|
|
49
|
+
format<T extends TypedIntlMessageGetter<object | undefined>>(
|
|
50
|
+
message: T,
|
|
51
|
+
values?: Omit<FormatValuesFor<T>, DefaultValues>,
|
|
52
|
+
) {
|
|
53
|
+
// A string literal means there's no locale dependency, so it can just be
|
|
54
|
+
// formatted directly without any subscription.
|
|
55
|
+
if (typeof message === 'string') return this.formatToParts(message);
|
|
56
|
+
|
|
57
|
+
return React.createElement(this.IntlMessage, { message, values });
|
|
58
|
+
}
|
|
59
|
+
}
|
package/src/types.d.ts
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import type { InternalIntlMessage } from './message';
|
|
2
|
+
import type { IntlMessageGetterAdditions } from './message-loader';
|
|
3
|
+
/**
|
|
4
|
+
* Use this function to define messages as part of the `@discord/intl`
|
|
5
|
+
* system, with type checking support.
|
|
6
|
+
*
|
|
7
|
+
* This function does not actually exist, and gets compiled out as part
|
|
8
|
+
* of the bundling process both on web and mobile. See the
|
|
9
|
+
* `@discord/intl/README.md` for more information.
|
|
10
|
+
*/
|
|
11
|
+
export declare function defineMessages(messages: object): object;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* A map of tag names to render functions to apply when the tags are
|
|
15
|
+
* encountered while formatting a message. Each `IntlManager` can be
|
|
16
|
+
* constructed with a different map of these elements, for example to allow
|
|
17
|
+
* one manager to render React output while another renders accessibility text.
|
|
18
|
+
*/
|
|
19
|
+
export type RichTextElementMap = Record<string, (chunks: any[]) => any>;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Messages with no rich formatting and no value interpolations are just plain
|
|
23
|
+
* strings, and are able to skip multiple formatting steps to work more
|
|
24
|
+
* efficiently. This type represents those messages distinctly.
|
|
25
|
+
*/
|
|
26
|
+
type PlainIntlMessage = string;
|
|
27
|
+
/**
|
|
28
|
+
* A richly-formatted message that injects dynamic values while formatting,
|
|
29
|
+
* with the types of those values defined by the `FormatValues` object. All
|
|
30
|
+
* messages of this type must have at least one value in the object, otherwise
|
|
31
|
+
* the message should be typed as a `PlainIntlMessage`.
|
|
32
|
+
*/
|
|
33
|
+
type TypedIntlMessage<FormatValues extends object> = InternalIntlMessage & {
|
|
34
|
+
values: FormatValues;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
type AnyIntlMessage<FormatValues extends object | undefined = undefined> =
|
|
38
|
+
FormatValues extends object ? TypedIntlMessage<FormatValues> : PlainIntlMessage;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Getter function that retrieves a message that best matches the requested
|
|
42
|
+
* locale. `FormatValues` represents either an object containing all of the
|
|
43
|
+
* value types that are required to format the message, or `undefined` to
|
|
44
|
+
* represent that there are no values required and the message is a plain
|
|
45
|
+
* string.
|
|
46
|
+
*/
|
|
47
|
+
export type TypedIntlMessageGetter<FormatValues extends object | undefined> =
|
|
48
|
+
IntlMessageGetterAdditions & {
|
|
49
|
+
(locale: string): AnyIntlMessage<FormatValues>;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Common utility type for converting a union type to an intersection of all
|
|
54
|
+
* the types in that union.
|
|
55
|
+
*/
|
|
56
|
+
type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends (k: infer I) => void
|
|
57
|
+
? I
|
|
58
|
+
: never;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Given a message getter of type `T` (which may be a union of multiple message
|
|
62
|
+
* types), return a combined type representing the intersection of all values
|
|
63
|
+
* needed to satisfy the mix of values.
|
|
64
|
+
*
|
|
65
|
+
* For example, given a variable representing one of two message getters, where
|
|
66
|
+
* one requires a single value to be formatted, but the other requires an
|
|
67
|
+
* additional value on top of that one:
|
|
68
|
+
*
|
|
69
|
+
* ```typescript
|
|
70
|
+
* // message1: "Hello {username}"
|
|
71
|
+
* // message2: "Hello {username}, it's {currentDate, date}"
|
|
72
|
+
* const message = someCondition ? message1 || message2;
|
|
73
|
+
* ```
|
|
74
|
+
*
|
|
75
|
+
* Attempting to format this message would require that both `username` _and_
|
|
76
|
+
* `currentDate` are supplied, so that all possible cases are covered, which is
|
|
77
|
+
* what this type resolves:
|
|
78
|
+
*
|
|
79
|
+
* ```typescript
|
|
80
|
+
* type ResolvedValuesTypes = FormatValuesFor<typeof message>;
|
|
81
|
+
* // => {username: string, currentDate: Date}
|
|
82
|
+
* ```
|
|
83
|
+
*
|
|
84
|
+
* This values type can then be used to enforce that all values are passed to
|
|
85
|
+
* functions that format the message, like `i18n.format`:
|
|
86
|
+
*
|
|
87
|
+
* ```typescript
|
|
88
|
+
* declare function format<T>(message: T, values: FormatValuesFor<T>): string;
|
|
89
|
+
* ```
|
|
90
|
+
*
|
|
91
|
+
* ## Mixed message types
|
|
92
|
+
*
|
|
93
|
+
* If T is a union of message types including both rich messages requiring
|
|
94
|
+
* values _and_ plain string messages with no values, this type will still
|
|
95
|
+
* properly resolve all of the required values, assuming that the getters for
|
|
96
|
+
* the plain messages use the convention of passing `undefined` as the generic
|
|
97
|
+
* argument, representing "no values should be passed for this message".
|
|
98
|
+
*
|
|
99
|
+
*
|
|
100
|
+
* ## Why this type?
|
|
101
|
+
*
|
|
102
|
+
* All "messages" are really defined as functions that return a specific
|
|
103
|
+
* value of the message for a requested locale, `(locale: string) => Message`.
|
|
104
|
+
* This is named `TypedIntlMessageGetter<T>`, where `T` is the definition of
|
|
105
|
+
* the values required to format the message.
|
|
106
|
+
*
|
|
107
|
+
* This type requires being passed the entire getter function rather than the
|
|
108
|
+
* returned message types, as narrowing to the returned type prevents
|
|
109
|
+
* TypeScript from distributing the union properly. This surfaces as error
|
|
110
|
+
* messages showing up on the _message_ type rather than the _value_ type when
|
|
111
|
+
* they don't match, which doesn't give the user any helpful information about
|
|
112
|
+
* what's wrong. This method ensures the value type is always the one being
|
|
113
|
+
* checked against the provided message types, and error messages will be
|
|
114
|
+
* accurate as a result.
|
|
115
|
+
*/
|
|
116
|
+
type FormatValuesFor<T> =
|
|
117
|
+
// `[]` syntax forces TypeScript to not distribute the possible union of
|
|
118
|
+
// types, meaning this represents "_all_ values of T are an extension of
|
|
119
|
+
// the parent type", which in this case represents messages with no
|
|
120
|
+
// format values. If that condition is matched, then `FormatValues` _cannot_
|
|
121
|
+
// be supplied, represented by the `never`. This prevents accidentally
|
|
122
|
+
// passing an object, even an empty one, for those strings, and allows the
|
|
123
|
+
// formatter to optimize a little more on each call.
|
|
124
|
+
[T] extends [TypedIntlMessageGetter<undefined>]
|
|
125
|
+
? never
|
|
126
|
+
: // The `never` conditino needs to be repeated here, but this time _without_
|
|
127
|
+
// the `[]` syntax, so that only the union elements with actual values are
|
|
128
|
+
// included in the union. Without this, the `infer U` on the latter side
|
|
129
|
+
// fails to resolve and the type just becomes `never`.
|
|
130
|
+
UnionToIntersection<
|
|
131
|
+
T extends TypedIntlMessageGetter<undefined>
|
|
132
|
+
? never
|
|
133
|
+
: T extends TypedIntlMessageGetter<infer U>
|
|
134
|
+
? U
|
|
135
|
+
: never
|
|
136
|
+
>;
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* A generic definition of a `format*` function that accepts any message
|
|
140
|
+
* argument as the first parameter, and the required values object for that
|
|
141
|
+
* message as the second parameter. The second parameter
|
|
142
|
+
*
|
|
143
|
+
* An optional third parameter for additional options can be specified as the
|
|
144
|
+
* second generic type parameter.
|
|
145
|
+
*/
|
|
146
|
+
export interface IntlFormatFn<
|
|
147
|
+
T extends object | undefined,
|
|
148
|
+
DefaultValues extends string | number | symbol,
|
|
149
|
+
OptionsType,
|
|
150
|
+
ResultType,
|
|
151
|
+
> {
|
|
152
|
+
(message: TypedIntlMessageGetter<T>, options?: OptionsType): ResultType;
|
|
153
|
+
(
|
|
154
|
+
message: TypedIntlMessageGetter<T>,
|
|
155
|
+
values: Omit<FormatValuesFor<T>, DefaultValues>,
|
|
156
|
+
options?: OptionsType,
|
|
157
|
+
): ResultType;
|
|
158
|
+
}
|