@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.
- package/dist/format.d.ts +30 -0
- package/dist/format.js +214 -0
- package/dist/formatters/ast.d.ts +67 -0
- package/dist/formatters/ast.js +148 -0
- package/dist/formatters/index.d.ts +4 -0
- package/dist/formatters/index.js +14 -0
- package/dist/formatters/markdown.d.ts +13 -0
- package/dist/formatters/markdown.js +80 -0
- package/dist/formatters/react.d.ts +28 -0
- package/dist/formatters/react.js +111 -0
- package/dist/formatters/string.d.ts +21 -0
- package/dist/formatters/string.js +90 -0
- package/dist/index.d.ts +7 -6
- package/dist/index.js +19 -4
- package/dist/intl-manager.d.ts +25 -63
- package/dist/intl-manager.js +61 -89
- package/dist/keyless-json.d.ts +8 -1
- package/dist/message-loader.d.ts +38 -6
- package/dist/message-loader.js +117 -22
- package/dist/message.d.ts +3 -38
- package/dist/message.js +1 -132
- package/dist/types.d.ts +41 -45
- package/package.json +1 -2
- package/src/format.ts +224 -0
- package/src/formatters/ast.ts +155 -0
- package/src/formatters/index.ts +9 -0
- package/src/formatters/markdown.ts +54 -0
- package/src/formatters/react.ts +100 -0
- package/src/formatters/string.ts +62 -0
- package/src/index.ts +7 -7
- package/src/intl-manager.ts +73 -161
- package/src/keyless-json.ts +7 -0
- package/src/message-loader.ts +91 -24
- package/src/message.ts +4 -96
- package/src/types.d.ts +41 -45
- package/dist/react.d.ts +0 -38
- package/dist/react.js +0 -80
- package/src/react.ts +0 -80
package/src/format.ts
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The core formatter, responsible for applying values to placeholders in
|
|
3
|
+
* messages and creating a static CST from the result. This method does _not_
|
|
4
|
+
* replace any of the rich-text formatting values (e.g., `$b` or `$link`) and
|
|
5
|
+
* leaves those to final formatters to apply as appropriate for the context
|
|
6
|
+
* of where the message is being rendered (e.g., React versus a plain string).
|
|
7
|
+
*
|
|
8
|
+
* The majority of this method is lifted from FormatJS' own `formatToParts`
|
|
9
|
+
* implementation, but is abstracted here to provide a few benefits:
|
|
10
|
+
* - working with the keyless AST format rather than having to transpose to the
|
|
11
|
+
* exact object format that FormatJS expects.
|
|
12
|
+
* - understanding the difference between rich text formatting values and
|
|
13
|
+
* user-provided values.
|
|
14
|
+
* - provide extra options related to the above for more control over how
|
|
15
|
+
* formatting operates, while abstracting out some of the common values.
|
|
16
|
+
* - adding extra context for hook and link functions to provide a `key`
|
|
17
|
+
* parameter, important to have React treat the resulting elements nicely.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { FormatJsNode, FormatJsNodeType } from './keyless-json';
|
|
21
|
+
import { Formats, Formatters, MissingValueError } from 'intl-messageformat';
|
|
22
|
+
import { RichTextTagNames } from './types';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Returns true if the tag name should be considered a rich text tag that
|
|
26
|
+
* applies formatting to a message, rather than being a user-supplied value.
|
|
27
|
+
*/
|
|
28
|
+
function isRichTextTag(name: string) {
|
|
29
|
+
return name[0] === '$';
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export abstract class FormatBuilder<Result> {
|
|
33
|
+
abstract pushRichTextTag(tag: RichTextTagNames, children: Result[]): void;
|
|
34
|
+
abstract pushLiteralText(text: string): void;
|
|
35
|
+
abstract pushObject(value: object): void;
|
|
36
|
+
abstract finish(): Result[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export type FormatBuilderConstructor<Result> = new () => FormatBuilder<Result>;
|
|
40
|
+
|
|
41
|
+
export function bindFormatValuesWithBuilder<T, Builder extends FormatBuilder<T>>(
|
|
42
|
+
builder: Builder,
|
|
43
|
+
nodes: FormatJsNode[],
|
|
44
|
+
locales: string | string[],
|
|
45
|
+
formatters: Formatters,
|
|
46
|
+
formats: Formats,
|
|
47
|
+
values: Record<string, string | object> = {},
|
|
48
|
+
currentPluralValue?: number,
|
|
49
|
+
originalMessage?: string,
|
|
50
|
+
) {
|
|
51
|
+
// Hot path for static messages that are just parsed as a single string element.
|
|
52
|
+
if (nodes.length === 1 && nodes[0].type === FormatJsNodeType.Literal) {
|
|
53
|
+
builder.pushLiteralText(nodes[0].value);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
for (const node of nodes) {
|
|
58
|
+
switch (node.type) {
|
|
59
|
+
case FormatJsNodeType.Literal:
|
|
60
|
+
builder.pushLiteralText(node.value);
|
|
61
|
+
continue;
|
|
62
|
+
|
|
63
|
+
case FormatJsNodeType.Pound:
|
|
64
|
+
// Replace `#` in plural rules with the actual numeric value. Only
|
|
65
|
+
// numeric values are replaced, otherwise the value is completed ignored?
|
|
66
|
+
// Behavior copied from FormatJS directly.
|
|
67
|
+
if (typeof currentPluralValue === 'number') {
|
|
68
|
+
const value = formatters.getNumberFormat(locales).format(currentPluralValue);
|
|
69
|
+
builder.pushLiteralText(value);
|
|
70
|
+
}
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const { value: variableName } = node;
|
|
75
|
+
// Enforce that all required values are provided by the caller, even if the
|
|
76
|
+
// actual value is falsy/undefined.
|
|
77
|
+
if (!(variableName in values) && !isRichTextTag(variableName)) {
|
|
78
|
+
throw new MissingValueError(variableName, originalMessage);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const value = values[variableName];
|
|
82
|
+
|
|
83
|
+
switch (node.type) {
|
|
84
|
+
case FormatJsNodeType.Argument:
|
|
85
|
+
// Empty values don't need to be added at all, they are purely for AST representation.
|
|
86
|
+
if (variableName == '$_') break;
|
|
87
|
+
|
|
88
|
+
if (typeof value === 'object') {
|
|
89
|
+
builder.pushObject(value);
|
|
90
|
+
} else {
|
|
91
|
+
// Taken from FormatJS: non-objects (strings, numbers, and falsy
|
|
92
|
+
// values) all get cast to strings immediately as literal nodes.
|
|
93
|
+
builder.pushLiteralText(String(value));
|
|
94
|
+
}
|
|
95
|
+
break;
|
|
96
|
+
case FormatJsNodeType.Date: {
|
|
97
|
+
// Distinct from FormatJS: We don't currently parse the skeleton ahead of time in the AST,
|
|
98
|
+
// so this manages parsing the skeleton as well before passing it onto the date formatter.
|
|
99
|
+
if (typeof node.style !== 'string') {
|
|
100
|
+
throw '@discord/intl does not currently support non-string format skeletons';
|
|
101
|
+
}
|
|
102
|
+
const style = node.style in formats.date ? formats.date[node.style] : undefined; // TODO: parseSkeleton();
|
|
103
|
+
// @ts-expect-error Cast string values to dates properly.
|
|
104
|
+
builder.pushLiteralText(formatters.getDateTimeFormat(locales, style).format(value));
|
|
105
|
+
break;
|
|
106
|
+
}
|
|
107
|
+
case FormatJsNodeType.Time: {
|
|
108
|
+
// Distinct from FormatJS: We don't currently parse the skeleton ahead of time in the AST,
|
|
109
|
+
// so this manages parsing the skeleton as well before passing it onto the date formatter.
|
|
110
|
+
if (typeof node.style !== 'string') {
|
|
111
|
+
throw '@discord/intl does not currently support non-string format skeletons';
|
|
112
|
+
}
|
|
113
|
+
const style = node.style in formats.time ? formats.time[node.style] : undefined; // TODO: parseSkeleton();
|
|
114
|
+
builder.pushLiteralText(
|
|
115
|
+
// @ts-expect-error Cast string values to dates properly.
|
|
116
|
+
formatters.getDateTimeFormat(locales, style).format(value),
|
|
117
|
+
);
|
|
118
|
+
break;
|
|
119
|
+
}
|
|
120
|
+
case FormatJsNodeType.Number: {
|
|
121
|
+
// Distinct from FormatJS: We don't currently parse the skeleton ahead of time in the AST,
|
|
122
|
+
// so this manages parsing the skeleton as well before passing it onto the date formatter.
|
|
123
|
+
if (typeof node.style !== 'string') {
|
|
124
|
+
throw '@discord/intl does not currently support non-string format skeletons';
|
|
125
|
+
}
|
|
126
|
+
const style = node.style in formats.number ? formats.number[node.style] : undefined; // TODO: parseSkeleton();
|
|
127
|
+
|
|
128
|
+
// @ts-expect-error Support `scale` style property.
|
|
129
|
+
const scaledValue = value as number;
|
|
130
|
+
// const scaledValue = (value as number) * (style?.scale ?? 1);
|
|
131
|
+
builder.pushLiteralText(formatters.getNumberFormat(locales, style).format(scaledValue));
|
|
132
|
+
break;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
case FormatJsNodeType.Tag: {
|
|
136
|
+
const { children } = node;
|
|
137
|
+
const appliedChildren = bindFormatValues(
|
|
138
|
+
builder.constructor as FormatBuilderConstructor<T>,
|
|
139
|
+
children,
|
|
140
|
+
locales,
|
|
141
|
+
formatters,
|
|
142
|
+
formats,
|
|
143
|
+
values,
|
|
144
|
+
currentPluralValue,
|
|
145
|
+
);
|
|
146
|
+
if (isRichTextTag(variableName)) {
|
|
147
|
+
builder.pushRichTextTag(variableName as RichTextTagNames, appliedChildren);
|
|
148
|
+
} else {
|
|
149
|
+
if (typeof value !== 'function') {
|
|
150
|
+
throw `expected a function type for a Tag formatting value, ${variableName}. got ${typeof value}: ${value}`;
|
|
151
|
+
}
|
|
152
|
+
let chunks = value(appliedChildren);
|
|
153
|
+
chunks = Array.isArray(chunks) ? chunks : [chunks];
|
|
154
|
+
for (const chunk of chunks) {
|
|
155
|
+
if (typeof chunk === 'string') {
|
|
156
|
+
builder.pushLiteralText(chunk);
|
|
157
|
+
} else {
|
|
158
|
+
builder.pushObject(chunk);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
break;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
case FormatJsNodeType.Select: {
|
|
166
|
+
const option = node.value in node.options ? node.options[node.value] : node.options.other;
|
|
167
|
+
if (option == null) {
|
|
168
|
+
throw `${node.value} is not a known option for select value ${variableName}. Valid options are ${Object.keys(node.options).join(', ')}`;
|
|
169
|
+
}
|
|
170
|
+
bindFormatValuesWithBuilder(builder, option.value, locales, formatters, formats, values);
|
|
171
|
+
break;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
case FormatJsNodeType.Plural: {
|
|
175
|
+
const option = (() => {
|
|
176
|
+
const exactSelector = `=${value}`;
|
|
177
|
+
if (exactSelector in node.options) return node.options[exactSelector];
|
|
178
|
+
const rule = formatters
|
|
179
|
+
.getPluralRules(locales, { type: node.pluralType })
|
|
180
|
+
// @ts-expect-error Assert this `as number` properly.
|
|
181
|
+
.select((value as number) - (node.offset ?? 0));
|
|
182
|
+
return node.options[rule] ?? node.options.other;
|
|
183
|
+
})();
|
|
184
|
+
|
|
185
|
+
if (option == null) {
|
|
186
|
+
throw `${node.value} is not a known option for plural value ${variableName}. Valid options are ${Object.keys(node.options).join(', ')}`;
|
|
187
|
+
}
|
|
188
|
+
bindFormatValuesWithBuilder(
|
|
189
|
+
builder,
|
|
190
|
+
option.value,
|
|
191
|
+
locales,
|
|
192
|
+
formatters,
|
|
193
|
+
formats,
|
|
194
|
+
values,
|
|
195
|
+
// @ts-expect-error assert this `as number` properly.
|
|
196
|
+
(value as number) - (node.offset ?? 0),
|
|
197
|
+
);
|
|
198
|
+
break;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export function bindFormatValues<Result>(
|
|
205
|
+
Builder: FormatBuilderConstructor<Result>,
|
|
206
|
+
nodes: FormatJsNode[],
|
|
207
|
+
locales: string | string[],
|
|
208
|
+
formatters: Formatters,
|
|
209
|
+
formats: Formats,
|
|
210
|
+
values: Record<string, string | object> = {},
|
|
211
|
+
currentPluralValue?: number,
|
|
212
|
+
): Result[] {
|
|
213
|
+
const builder = new Builder();
|
|
214
|
+
bindFormatValuesWithBuilder(
|
|
215
|
+
builder,
|
|
216
|
+
nodes,
|
|
217
|
+
locales,
|
|
218
|
+
formatters,
|
|
219
|
+
formats,
|
|
220
|
+
values,
|
|
221
|
+
currentPluralValue,
|
|
222
|
+
);
|
|
223
|
+
return builder.finish();
|
|
224
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AnyIntlMessage,
|
|
3
|
+
FormatterImplementation,
|
|
4
|
+
FunctionTypes,
|
|
5
|
+
type RichTextFormattingMap,
|
|
6
|
+
RichTextTagNames,
|
|
7
|
+
} from '../types';
|
|
8
|
+
import { IntlManager } from '../intl-manager';
|
|
9
|
+
import { FormatBuilder } from '../format';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Types for formatting functions when calling `formatToParts`, ensuring the
|
|
13
|
+
* functions yield value AST nodes.
|
|
14
|
+
*/
|
|
15
|
+
export type AstFunctionTypes = FunctionTypes<RichTextNode>;
|
|
16
|
+
|
|
17
|
+
// This structure aims to match `simple-markdown`'s AST, but that type is very
|
|
18
|
+
// loosely defined as just "type" and "arbitrary map of content". This is more
|
|
19
|
+
// explicit and type-safe.
|
|
20
|
+
export enum RichTextNodeType {
|
|
21
|
+
Text = 'text',
|
|
22
|
+
Strong = 'strong',
|
|
23
|
+
Emphasis = 'emphasis',
|
|
24
|
+
Code = 'code',
|
|
25
|
+
Link = 'link',
|
|
26
|
+
Paragraph = 'paragraph',
|
|
27
|
+
/**
|
|
28
|
+
* A special extension for when the value of a placeholder is an object.
|
|
29
|
+
* Rather than casting to a string here, the object is preserved as-is for a
|
|
30
|
+
* future formatter to render as appropriate (i.e., using an object as a
|
|
31
|
+
* value when formatting to an AST should keep that object structure so that
|
|
32
|
+
* the final AST is accurate for what the user expects, but when rendering to
|
|
33
|
+
* a plain string, the final formatter can just cast that object to a string
|
|
34
|
+
* using its `toString` method or some other mechanism).
|
|
35
|
+
*/
|
|
36
|
+
Object = 'object',
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const RICH_TEXT_TAG_TYPES: Record<RichTextTagNames, RichTextNodeType> = {
|
|
40
|
+
$_: RichTextNodeType.Text,
|
|
41
|
+
$b: RichTextNodeType.Strong,
|
|
42
|
+
$i: RichTextNodeType.Emphasis,
|
|
43
|
+
$code: RichTextNodeType.Code,
|
|
44
|
+
$link: RichTextNodeType.Link,
|
|
45
|
+
$p: RichTextNodeType.Paragraph,
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
interface RichTextNodeBase<ContentType> {
|
|
49
|
+
type: RichTextNodeType;
|
|
50
|
+
content: ContentType;
|
|
51
|
+
}
|
|
52
|
+
interface RichTextTextNode extends RichTextNodeBase<string> {
|
|
53
|
+
type: RichTextNodeType.Text;
|
|
54
|
+
}
|
|
55
|
+
interface RichTextStrongNode extends RichTextNodeBase<RichTextNode[]> {
|
|
56
|
+
type: RichTextNodeType.Strong;
|
|
57
|
+
}
|
|
58
|
+
interface RichTextEmphasisNode extends RichTextNodeBase<RichTextNode[]> {
|
|
59
|
+
type: RichTextNodeType.Emphasis;
|
|
60
|
+
}
|
|
61
|
+
interface RichTextCodeNode extends RichTextNodeBase<RichTextNode[]> {
|
|
62
|
+
type: RichTextNodeType.Code;
|
|
63
|
+
}
|
|
64
|
+
interface RichTextParagraphNode extends RichTextNodeBase<RichTextNode[]> {
|
|
65
|
+
type: RichTextNodeType.Paragraph;
|
|
66
|
+
}
|
|
67
|
+
interface RichTextLinkNode extends RichTextNodeBase<RichTextNode[]> {
|
|
68
|
+
type: RichTextNodeType.Link;
|
|
69
|
+
target: string;
|
|
70
|
+
}
|
|
71
|
+
interface RichTextObjectNode extends RichTextNodeBase<object> {
|
|
72
|
+
type: RichTextNodeType.Object;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export type RichTextNode =
|
|
76
|
+
| RichTextTextNode
|
|
77
|
+
| RichTextStrongNode
|
|
78
|
+
| RichTextEmphasisNode
|
|
79
|
+
| RichTextCodeNode
|
|
80
|
+
| RichTextParagraphNode
|
|
81
|
+
| RichTextLinkNode
|
|
82
|
+
| RichTextObjectNode;
|
|
83
|
+
|
|
84
|
+
const AST_RICH_TEXT_ELEMENTS: RichTextFormattingMap<AstFunctionTypes['hook']> = {
|
|
85
|
+
$_: () => ({ type: RichTextNodeType.Text, content: '' }),
|
|
86
|
+
$b: (content) => ({ type: RichTextNodeType.Strong, content }),
|
|
87
|
+
$code: (content) => ({ type: RichTextNodeType.Code, content }),
|
|
88
|
+
$i: (content) => ({ type: RichTextNodeType.Emphasis, content }),
|
|
89
|
+
$link: ([target, ...content]) => ({
|
|
90
|
+
type: RichTextNodeType.Link,
|
|
91
|
+
target: target.content as string,
|
|
92
|
+
content,
|
|
93
|
+
}),
|
|
94
|
+
$p: (content) => ({ type: RichTextNodeType.Paragraph, content }),
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
class AstBuilder extends FormatBuilder<RichTextNode> {
|
|
98
|
+
result: RichTextNode[] = [];
|
|
99
|
+
|
|
100
|
+
pushRichTextTag(tag: RichTextTagNames, children: RichTextNode[]) {
|
|
101
|
+
if (!(tag in AST_RICH_TEXT_ELEMENTS)) {
|
|
102
|
+
throw `${tag} is not a known rich text formatting tag`;
|
|
103
|
+
}
|
|
104
|
+
const result = AST_RICH_TEXT_ELEMENTS[tag](children);
|
|
105
|
+
if (Array.isArray(result)) {
|
|
106
|
+
this.result.push(...result);
|
|
107
|
+
} else {
|
|
108
|
+
this.result.push(result);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
pushLiteralText(text: string) {
|
|
113
|
+
const last = this.result[this.result.length - 1];
|
|
114
|
+
if (last.type === RichTextNodeType.Text) {
|
|
115
|
+
last.content += text;
|
|
116
|
+
} else {
|
|
117
|
+
this.result.push({ type: RichTextNodeType.Text, content: text });
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
pushObject(value: object) {
|
|
122
|
+
this.result.push({ type: RichTextNodeType.Object, content: value });
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
finish(): RichTextNode[] {
|
|
126
|
+
return this.result;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Format the given message in the current locale with the provided values.
|
|
132
|
+
* The returned values is _always_ an Array of parts, even if the message is
|
|
133
|
+
* a simple string value.
|
|
134
|
+
*
|
|
135
|
+
* This function is the basis of how messages are normally formatted, and can
|
|
136
|
+
* be used anywhere. However, it is not reactive and only functions on the
|
|
137
|
+
* data that is currently loaded and known. For a reactive function that
|
|
138
|
+
* automatically updates when the locale changes or when new data is loaded,
|
|
139
|
+
* use `format`, which will wrap the formatting in a React component that
|
|
140
|
+
* subscribes to the current locale and state of loaded messages.
|
|
141
|
+
*/
|
|
142
|
+
export function formatToAst(
|
|
143
|
+
this: IntlManager,
|
|
144
|
+
message: AnyIntlMessage,
|
|
145
|
+
values: object,
|
|
146
|
+
): RichTextNode[] {
|
|
147
|
+
if (typeof message === 'string') return [{ type: RichTextNodeType.Text, content: message }];
|
|
148
|
+
|
|
149
|
+
return this.bindFormatValues(AstBuilder, message, values);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export const astFormatter: FormatterImplementation<AstFunctionTypes, RichTextNode[]> = {
|
|
153
|
+
format: formatToAst,
|
|
154
|
+
builder: AstBuilder,
|
|
155
|
+
};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export { astFormatter, type AstFunctionTypes, type RichTextNode, RichTextNodeType } from './ast';
|
|
2
|
+
export { markdownFormatter, type MarkdownFunctionTypes } from './markdown';
|
|
3
|
+
export {
|
|
4
|
+
reactFormatter,
|
|
5
|
+
makeReactFormatter,
|
|
6
|
+
DEFAULT_REACT_RICH_TEXT_ELEMENTS,
|
|
7
|
+
type ReactFunctionTypes,
|
|
8
|
+
} from './react';
|
|
9
|
+
export { stringFormatter, type StringFunctionTypes } from './string';
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Similar to `formatToPlainString`, format the given message with the provided
|
|
3
|
+
* values, but convert all rich text formatting back to Markdown syntax rather
|
|
4
|
+
* than rendering the actual rich content. The result is a plain string that
|
|
5
|
+
* can be sent through a separate Markdown renderer to get an equivalent
|
|
6
|
+
* result to formatting this message directly.
|
|
7
|
+
*/
|
|
8
|
+
import {
|
|
9
|
+
AnyIntlMessage,
|
|
10
|
+
FormatterImplementation,
|
|
11
|
+
FunctionTypes,
|
|
12
|
+
RichTextFormattingMap,
|
|
13
|
+
RichTextTagNames,
|
|
14
|
+
} from '../types';
|
|
15
|
+
import { IntlManager } from '../intl-manager';
|
|
16
|
+
import { FormatBuilderConstructor } from '../format';
|
|
17
|
+
import { StringBuilder } from './string';
|
|
18
|
+
|
|
19
|
+
export type MarkdownFunctionTypes = FunctionTypes<string>;
|
|
20
|
+
|
|
21
|
+
const MARKDOWN_RICH_TEXT_ELEMENTS: RichTextFormattingMap<MarkdownFunctionTypes['hook']> = {
|
|
22
|
+
$_: () => '',
|
|
23
|
+
$b: (content) => '**' + content.join('') + '**',
|
|
24
|
+
$code: (content) => '`' + content.join('') + '`',
|
|
25
|
+
$i: (content) => '*' + content.join('') + '*',
|
|
26
|
+
$link: ([target, ...content]) => '[' + content.join('') + '](' + target + ')',
|
|
27
|
+
$p: (content) => content.join('') + '\n\n',
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
class MarkdownBuilder extends StringBuilder {
|
|
31
|
+
result: string = '';
|
|
32
|
+
|
|
33
|
+
pushRichTextTag(tag: RichTextTagNames, children: string[]) {
|
|
34
|
+
this.result += MARKDOWN_RICH_TEXT_ELEMENTS[tag](children);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function formatToMarkdownString(
|
|
39
|
+
this: IntlManager,
|
|
40
|
+
message: AnyIntlMessage,
|
|
41
|
+
values: object,
|
|
42
|
+
Builder: FormatBuilderConstructor<string> = MarkdownBuilder,
|
|
43
|
+
): string {
|
|
44
|
+
if (typeof message === 'string') return message;
|
|
45
|
+
|
|
46
|
+
const result = this.bindFormatValues(Builder, message, values);
|
|
47
|
+
// MarkdownBuilder always creates a single-element array with the string value.
|
|
48
|
+
return result[0];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export const markdownFormatter: FormatterImplementation<MarkdownFunctionTypes, string> = {
|
|
52
|
+
format: formatToMarkdownString,
|
|
53
|
+
builder: MarkdownBuilder,
|
|
54
|
+
};
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Format the given message as a React component, allowing it to listen for
|
|
3
|
+
* and respond to updates about the current locale and other relevant
|
|
4
|
+
* information.
|
|
5
|
+
*/
|
|
6
|
+
import type {
|
|
7
|
+
AnyIntlMessage,
|
|
8
|
+
FormatterImplementation,
|
|
9
|
+
FunctionTypes,
|
|
10
|
+
RichTextFormattingMap,
|
|
11
|
+
RichTextTagNames,
|
|
12
|
+
} from '../types';
|
|
13
|
+
import * as React from 'react';
|
|
14
|
+
import { IntlManager } from '../intl-manager';
|
|
15
|
+
import { FormatBuilder, FormatBuilderConstructor } from '../format';
|
|
16
|
+
|
|
17
|
+
export type ReactHandlerEvent = React.MouseEvent | React.KeyboardEvent;
|
|
18
|
+
export type ReactClickHandler = (e: ReactHandlerEvent) => void;
|
|
19
|
+
|
|
20
|
+
export type ReactFunctionTypes = FunctionTypes<
|
|
21
|
+
React.ReactNode,
|
|
22
|
+
| ReactClickHandler
|
|
23
|
+
| { onClick: ReactClickHandler }
|
|
24
|
+
| { onContextMenu: ReactClickHandler }
|
|
25
|
+
| { onClick: ReactClickHandler; onContextMenu: ReactClickHandler }
|
|
26
|
+
>;
|
|
27
|
+
|
|
28
|
+
const h = React.createElement;
|
|
29
|
+
export const DEFAULT_REACT_RICH_TEXT_ELEMENTS: RichTextFormattingMap<ReactFunctionTypes['hook']> = {
|
|
30
|
+
$_: () => '',
|
|
31
|
+
$b: (content) => h('strong', undefined, content),
|
|
32
|
+
$code: (content) => h('code', undefined, content),
|
|
33
|
+
$i: (content) => h('em', undefined, content),
|
|
34
|
+
// $link will always be [href, <empty>, ...content]
|
|
35
|
+
$link: ([href, ...content]) => h('a', { href }, content),
|
|
36
|
+
$p: (content) => h('p', undefined, content),
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Creates a new `FormatBuilder` class that constructs a React element tree
|
|
41
|
+
* from the message using the given `richTextElements` to apply formatting.
|
|
42
|
+
* This allows consumers to inject their own design system components and
|
|
43
|
+
* overrides for rendering elements like links, paragraphs, and code blocks.
|
|
44
|
+
* @param richTextElements
|
|
45
|
+
*/
|
|
46
|
+
function createReactBuilder(richTextElements: RichTextFormattingMap<ReactFunctionTypes['hook']>): {
|
|
47
|
+
new (): FormatBuilder<React.ReactNode>;
|
|
48
|
+
} {
|
|
49
|
+
return class extends FormatBuilder<React.ReactNode> {
|
|
50
|
+
result: React.ReactNode[] = [];
|
|
51
|
+
|
|
52
|
+
pushRichTextTag(tag: RichTextTagNames, children: React.ReactNode[]) {
|
|
53
|
+
this.result.push(richTextElements[tag](children));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
pushLiteralText(text: string) {
|
|
57
|
+
this.result.push(text);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
pushObject(value: object) {
|
|
61
|
+
// @ts-expect-error this is technically invalid, but we'll just assume that if a format returns
|
|
62
|
+
// an object, it'll be acting as some form of ReactNode.
|
|
63
|
+
this.result.push(value);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
finish(): React.ReactNode[] {
|
|
67
|
+
return this.result;
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function formatReact(
|
|
73
|
+
this: IntlManager,
|
|
74
|
+
message: AnyIntlMessage,
|
|
75
|
+
values: object,
|
|
76
|
+
Builder: FormatBuilderConstructor<React.ReactElement>,
|
|
77
|
+
): React.ReactElement {
|
|
78
|
+
if (typeof message === 'string') {
|
|
79
|
+
return React.createElement(React.Fragment, undefined, message);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const parts = this.bindFormatValues(Builder, message, values);
|
|
83
|
+
return React.createElement(React.Fragment, undefined, parts);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Create a new React formatter with the given rich text elements replacing the defaults. Use this
|
|
88
|
+
* function to inject custom components for things like links and paragraphs, which may be best
|
|
89
|
+
* suited to use components from a Design System or other library rather than native DOM elements.
|
|
90
|
+
*/
|
|
91
|
+
export function makeReactFormatter(
|
|
92
|
+
richTextElements: RichTextFormattingMap<ReactFunctionTypes['hook']>,
|
|
93
|
+
): FormatterImplementation<ReactFunctionTypes, React.ReactElement, React.ReactNode> {
|
|
94
|
+
return {
|
|
95
|
+
format: formatReact,
|
|
96
|
+
builder: createReactBuilder(richTextElements),
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export const reactFormatter = makeReactFormatter(DEFAULT_REACT_RICH_TEXT_ELEMENTS);
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Format the given message with the provided values, removing any styling
|
|
3
|
+
* and non-textual content from the message, returning a plain string.
|
|
4
|
+
*/
|
|
5
|
+
import { AnyIntlMessage, FormatterImplementation, FunctionTypes, RichTextTagNames } from '../types';
|
|
6
|
+
import { FormatBuilder } from '../format';
|
|
7
|
+
import { IntlManager } from '../intl-manager';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Types for formatting functions when calling `bindFormatValues`, ensuring the
|
|
11
|
+
* functions always yield plain strings.
|
|
12
|
+
*/
|
|
13
|
+
export type StringFunctionTypes = FunctionTypes<string>;
|
|
14
|
+
|
|
15
|
+
export class StringBuilder extends FormatBuilder<string> {
|
|
16
|
+
result: string = '';
|
|
17
|
+
|
|
18
|
+
pushRichTextTag(tag: RichTextTagNames, children: string[]) {
|
|
19
|
+
// Plain string formatting ignores rich text tags and just takes the
|
|
20
|
+
// visible content from the children. This means the `target` element
|
|
21
|
+
// of a link is ignored, otherwise all the children are just joined
|
|
22
|
+
// together directly.
|
|
23
|
+
if (tag === '$link') {
|
|
24
|
+
this.result += children.slice(1).join('');
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
this.result += children.join('');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
pushLiteralText(text: string) {
|
|
31
|
+
this.result += text;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
pushObject(value: object) {
|
|
35
|
+
// Objects are only included in the result if they specify a toString value directly.
|
|
36
|
+
// Otherwise, they would be rendered as `[object Object]`, which is never helpful.
|
|
37
|
+
if ('toString' in value) {
|
|
38
|
+
this.result += value.toString();
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
finish(): string[] {
|
|
43
|
+
return [this.result];
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function formatToPlainString(
|
|
48
|
+
this: IntlManager,
|
|
49
|
+
message: AnyIntlMessage,
|
|
50
|
+
values: object,
|
|
51
|
+
): string {
|
|
52
|
+
if (typeof message === 'string') return message;
|
|
53
|
+
|
|
54
|
+
const result = this.bindFormatValues(StringBuilder, message, values);
|
|
55
|
+
// StringBuilder always creates a single element array with the string value.
|
|
56
|
+
return result[0];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export const stringFormatter: FormatterImplementation<StringFunctionTypes, string> = {
|
|
60
|
+
format: formatToPlainString,
|
|
61
|
+
builder: StringBuilder,
|
|
62
|
+
};
|
package/src/index.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
export { IntlManager, createLoader, DEFAULT_LOCALE } from './intl-manager';
|
|
5
|
-
export { IntlManagerReact } from './react';
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import { RichTextNode } from './formatters';
|
|
6
3
|
|
|
4
|
+
export * from './formatters';
|
|
5
|
+
export { IntlManager, DEFAULT_LOCALE, type FormatFunction } from './intl-manager';
|
|
6
|
+
export { createLoader, waitForAllDefaultIntlMessagesLoaded } from './message-loader';
|
|
7
7
|
export type * from './types.d.ts';
|
|
8
8
|
|
|
9
9
|
/**
|
|
@@ -21,7 +21,7 @@ export type * from './types.d.ts';
|
|
|
21
21
|
* intl message, even if the actual value comes from elsewhere (like a
|
|
22
22
|
* user-generated string).
|
|
23
23
|
*/
|
|
24
|
-
export type ReactIntlMessage = string |
|
|
24
|
+
export type ReactIntlMessage = string | React.ReactElement;
|
|
25
25
|
|
|
26
26
|
/**
|
|
27
27
|
* The return value of `formatToParts` from `@discord/intl`, this type
|
|
@@ -33,4 +33,4 @@ export type ReactIntlMessage = string | ReturnType<IntlManagerReact<any>['format
|
|
|
33
33
|
* from a call to `formatToParts`, meaning they are intended to be fully static
|
|
34
34
|
* structures passed around for custom rendering functions to use.
|
|
35
35
|
*/
|
|
36
|
-
export type IntlMessageAst =
|
|
36
|
+
export type IntlMessageAst = RichTextNode;
|