@discord/intl 0.2.1-rc.1 → 0.2.2-canary.6898c23

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.js CHANGED
@@ -34,6 +34,7 @@ exports.bindFormatValuesWithBuilder = bindFormatValuesWithBuilder;
34
34
  exports.bindFormatValues = bindFormatValues;
35
35
  var keyless_json_1 = require("./keyless-json");
36
36
  var intl_messageformat_1 = require("intl-messageformat");
37
+ var icu_skeleton_parser_1 = require("@formatjs/icu-skeleton-parser");
37
38
  /**
38
39
  * Returns true if the tag name should be considered a rich text tag that
39
40
  * applies formatting to a message, rather than being a user-supplied value.
@@ -49,7 +50,7 @@ var FormatBuilder = /** @class */ (function () {
49
50
  exports.FormatBuilder = FormatBuilder;
50
51
  function bindFormatValuesWithBuilder(builder, nodes, locales, formatters, formats, values, currentPluralValue, originalMessage) {
51
52
  var e_1, _a;
52
- var _b;
53
+ var _b, _c;
53
54
  if (values === void 0) { values = {}; }
54
55
  // Hot path for static messages that are just parsed as a single string element.
55
56
  if (nodes.length === 1 && nodes[0].type === keyless_json_1.FormatJsNodeType.Literal) {
@@ -57,7 +58,7 @@ function bindFormatValuesWithBuilder(builder, nodes, locales, formatters, format
57
58
  return;
58
59
  }
59
60
  var _loop_1 = function (node) {
60
- var e_2, _c;
61
+ var e_2, _d;
61
62
  switch (node.type) {
62
63
  case keyless_json_1.FormatJsNodeType.Literal:
63
64
  builder.pushLiteralText(node.value);
@@ -96,10 +97,11 @@ function bindFormatValuesWithBuilder(builder, nodes, locales, formatters, format
96
97
  case keyless_json_1.FormatJsNodeType.Date: {
97
98
  // Distinct from FormatJS: We don't currently parse the skeleton ahead of time in the AST,
98
99
  // 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
- var style = node.style in formats.date ? formats.date[node.style] : undefined; // TODO: parseSkeleton();
100
+ var style = node.style in formats.date
101
+ ? formats.date[node.style]
102
+ : node.style != null
103
+ ? (0, icu_skeleton_parser_1.parseDateTimeSkeleton)(node.style)
104
+ : undefined;
103
105
  // @ts-expect-error Cast string values to dates properly.
104
106
  builder.pushLiteralText(formatters.getDateTimeFormat(locales, style).format(value));
105
107
  break;
@@ -107,10 +109,11 @@ function bindFormatValuesWithBuilder(builder, nodes, locales, formatters, format
107
109
  case keyless_json_1.FormatJsNodeType.Time: {
108
110
  // Distinct from FormatJS: We don't currently parse the skeleton ahead of time in the AST,
109
111
  // 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
- var style = node.style in formats.time ? formats.time[node.style] : undefined; // TODO: parseSkeleton();
112
+ var style = node.style in formats.time
113
+ ? formats.time[node.style]
114
+ : node.style != null
115
+ ? (0, icu_skeleton_parser_1.parseDateTimeSkeleton)(node.style)
116
+ : undefined; // TODO: parseSkeleton();
114
117
  builder.pushLiteralText(
115
118
  // @ts-expect-error Cast string values to dates properly.
116
119
  formatters.getDateTimeFormat(locales, style).format(value));
@@ -119,13 +122,14 @@ function bindFormatValuesWithBuilder(builder, nodes, locales, formatters, format
119
122
  case keyless_json_1.FormatJsNodeType.Number: {
120
123
  // Distinct from FormatJS: We don't currently parse the skeleton ahead of time in the AST,
121
124
  // so this manages parsing the skeleton as well before passing it onto the date formatter.
122
- if (typeof node.style !== 'string') {
123
- throw '@discord/intl does not currently support non-string format skeletons';
124
- }
125
- var style = node.style in formats.number ? formats.number[node.style] : undefined; // TODO: parseSkeleton();
126
- // @ts-expect-error Support `scale` style property.
127
- var scaledValue = value;
128
- // const scaledValue = (value as number) * (style?.scale ?? 1);
125
+ var style = node.style in formats.number
126
+ ? formats.number[node.style]
127
+ : node.style != null
128
+ ? (0, icu_skeleton_parser_1.parseNumberSkeleton)((0, icu_skeleton_parser_1.parseNumberSkeletonFromString)(node.style))
129
+ : undefined;
130
+ var scaledValue =
131
+ // @ts-expect-error This is a weird cast that's not accurate, but works in the short term.
132
+ typeof value !== 'number' ? value : ((_b = value * (style === null || style === void 0 ? void 0 : style.scale)) !== null && _b !== void 0 ? _b : 1);
129
133
  builder.pushLiteralText(formatters.getNumberFormat(locales, style).format(scaledValue));
130
134
  break;
131
135
  }
@@ -155,7 +159,7 @@ function bindFormatValuesWithBuilder(builder, nodes, locales, formatters, format
155
159
  catch (e_2_1) { e_2 = { error: e_2_1 }; }
156
160
  finally {
157
161
  try {
158
- if (chunks_1_1 && !chunks_1_1.done && (_c = chunks_1.return)) _c.call(chunks_1);
162
+ if (chunks_1_1 && !chunks_1_1.done && (_d = chunks_1.return)) _d.call(chunks_1);
159
163
  }
160
164
  finally { if (e_2) throw e_2.error; }
161
165
  }
@@ -187,7 +191,7 @@ function bindFormatValuesWithBuilder(builder, nodes, locales, formatters, format
187
191
  }
188
192
  bindFormatValuesWithBuilder(builder, option.value, locales, formatters, formats, values,
189
193
  // @ts-expect-error assert this `as number` properly.
190
- value - ((_b = node.offset) !== null && _b !== void 0 ? _b : 0));
194
+ value - ((_c = node.offset) !== null && _c !== void 0 ? _c : 0));
191
195
  break;
192
196
  }
193
197
  }
@@ -4,12 +4,13 @@ import { IntlManager } from '../intl-manager';
4
4
  * Types for formatting functions when calling `formatToParts`, ensuring the
5
5
  * functions yield value AST nodes.
6
6
  */
7
- export type AstFunctionTypes = FunctionTypes<RichTextNode>;
7
+ export type AstFunctionTypes = FunctionTypes<RichTextNode, object>;
8
8
  export declare enum RichTextNodeType {
9
9
  Text = "text",
10
10
  Strong = "strong",
11
- Emphasis = "emphasis",
12
- Code = "code",
11
+ Emphasis = "em",
12
+ Strikethrough = "s",
13
+ Code = "inlineCode",
13
14
  Link = "link",
14
15
  Paragraph = "paragraph",
15
16
  /**
@@ -36,6 +37,9 @@ interface RichTextStrongNode extends RichTextNodeBase<RichTextNode[]> {
36
37
  interface RichTextEmphasisNode extends RichTextNodeBase<RichTextNode[]> {
37
38
  type: RichTextNodeType.Emphasis;
38
39
  }
40
+ interface RichTextStrikethroughNode extends RichTextNodeBase<RichTextNode[]> {
41
+ type: RichTextNodeType.Strikethrough;
42
+ }
39
43
  interface RichTextCodeNode extends RichTextNodeBase<RichTextNode[]> {
40
44
  type: RichTextNodeType.Code;
41
45
  }
@@ -49,7 +53,7 @@ interface RichTextLinkNode extends RichTextNodeBase<RichTextNode[]> {
49
53
  interface RichTextObjectNode extends RichTextNodeBase<object> {
50
54
  type: RichTextNodeType.Object;
51
55
  }
52
- export type RichTextNode = RichTextTextNode | RichTextStrongNode | RichTextEmphasisNode | RichTextCodeNode | RichTextParagraphNode | RichTextLinkNode | RichTextObjectNode;
56
+ export type RichTextNode = RichTextTextNode | RichTextStrongNode | RichTextEmphasisNode | RichTextStrikethroughNode | RichTextCodeNode | RichTextParagraphNode | RichTextLinkNode | RichTextObjectNode;
53
57
  /**
54
58
  * Format the given message in the current locale with the provided values.
55
59
  * The returned values is _always_ an Array of parts, even if the message is
@@ -50,8 +50,9 @@ var RichTextNodeType;
50
50
  (function (RichTextNodeType) {
51
51
  RichTextNodeType["Text"] = "text";
52
52
  RichTextNodeType["Strong"] = "strong";
53
- RichTextNodeType["Emphasis"] = "emphasis";
54
- RichTextNodeType["Code"] = "code";
53
+ RichTextNodeType["Emphasis"] = "em";
54
+ RichTextNodeType["Strikethrough"] = "s";
55
+ RichTextNodeType["Code"] = "inlineCode";
55
56
  RichTextNodeType["Link"] = "link";
56
57
  RichTextNodeType["Paragraph"] = "paragraph";
57
58
  /**
@@ -65,19 +66,12 @@ var RichTextNodeType;
65
66
  */
66
67
  RichTextNodeType["Object"] = "object";
67
68
  })(RichTextNodeType || (exports.RichTextNodeType = RichTextNodeType = {}));
68
- var RICH_TEXT_TAG_TYPES = {
69
- $_: RichTextNodeType.Text,
70
- $b: RichTextNodeType.Strong,
71
- $i: RichTextNodeType.Emphasis,
72
- $code: RichTextNodeType.Code,
73
- $link: RichTextNodeType.Link,
74
- $p: RichTextNodeType.Paragraph,
75
- };
76
69
  var AST_RICH_TEXT_ELEMENTS = {
77
70
  $_: function () { return ({ type: RichTextNodeType.Text, content: '' }); },
78
71
  $b: function (content) { return ({ type: RichTextNodeType.Strong, content: content }); },
79
- $code: function (content) { return ({ type: RichTextNodeType.Code, content: content }); },
80
72
  $i: function (content) { return ({ type: RichTextNodeType.Emphasis, content: content }); },
73
+ $del: function (content) { return ({ type: RichTextNodeType.Strikethrough, content: content }); },
74
+ $code: function (content) { return ({ type: RichTextNodeType.Code, content: content }); },
81
75
  $link: function (_a) {
82
76
  var _b = __read(_a), target = _b[0], content = _b.slice(1);
83
77
  return ({
@@ -100,7 +94,7 @@ var AstBuilder = /** @class */ (function (_super) {
100
94
  if (!(tag in AST_RICH_TEXT_ELEMENTS)) {
101
95
  throw "".concat(tag, " is not a known rich text formatting tag");
102
96
  }
103
- var result = AST_RICH_TEXT_ELEMENTS[tag](children);
97
+ var result = AST_RICH_TEXT_ELEMENTS[tag](children, '');
104
98
  if (Array.isArray(result)) {
105
99
  (_a = this.result).push.apply(_a, __spreadArray([], __read(result), false));
106
100
  }
@@ -110,7 +104,7 @@ var AstBuilder = /** @class */ (function (_super) {
110
104
  };
111
105
  AstBuilder.prototype.pushLiteralText = function (text) {
112
106
  var last = this.result[this.result.length - 1];
113
- if (last.type === RichTextNodeType.Text) {
107
+ if (last != null && last.type === RichTextNodeType.Text) {
114
108
  last.content += text;
115
109
  }
116
110
  else {
@@ -46,8 +46,9 @@ var string_1 = require("./string");
46
46
  var MARKDOWN_RICH_TEXT_ELEMENTS = {
47
47
  $_: function () { return ''; },
48
48
  $b: function (content) { return '**' + content.join('') + '**'; },
49
- $code: function (content) { return '`' + content.join('') + '`'; },
50
49
  $i: function (content) { return '*' + content.join('') + '*'; },
50
+ $del: function (content) { return '~~' + content.join('') + '~~'; },
51
+ $code: function (content) { return '`' + content.join('') + '`'; },
51
52
  $link: function (_a) {
52
53
  var _b = __read(_a), target = _b[0], content = _b.slice(1);
53
54
  return '[' + content.join('') + '](' + target + ')';
@@ -62,7 +63,7 @@ var MarkdownBuilder = /** @class */ (function (_super) {
62
63
  return _this;
63
64
  }
64
65
  MarkdownBuilder.prototype.pushRichTextTag = function (tag, children) {
65
- this.result += MARKDOWN_RICH_TEXT_ELEMENTS[tag](children);
66
+ this.result += MARKDOWN_RICH_TEXT_ELEMENTS[tag](children, '');
66
67
  };
67
68
  return MarkdownBuilder;
68
69
  }(string_1.StringBuilder));
@@ -18,11 +18,14 @@ export type ReactFunctionTypes = FunctionTypes<React.ReactNode, ReactClickHandle
18
18
  onContextMenu: ReactClickHandler;
19
19
  }>;
20
20
  export declare const DEFAULT_REACT_RICH_TEXT_ELEMENTS: RichTextFormattingMap<ReactFunctionTypes['hook']>;
21
- export declare function formatReact(this: IntlManager, message: AnyIntlMessage, values: object, Builder: FormatBuilderConstructor<React.ReactElement>): React.ReactElement;
21
+ export type ReactIntlMessage = React.ReactElement<{}, typeof React.Fragment & {
22
+ __brand: 'discord-intl';
23
+ }>;
24
+ export declare function formatReact(this: IntlManager, message: AnyIntlMessage, values: object, Builder: FormatBuilderConstructor<React.ReactElement>): ReactIntlMessage;
22
25
  /**
23
26
  * Create a new React formatter with the given rich text elements replacing the defaults. Use this
24
27
  * function to inject custom components for things like links and paragraphs, which may be best
25
28
  * suited to use components from a Design System or other library rather than native DOM elements.
26
29
  */
27
- export declare function makeReactFormatter(richTextElements: RichTextFormattingMap<ReactFunctionTypes['hook']>): FormatterImplementation<ReactFunctionTypes, React.ReactElement, React.ReactNode>;
28
- export declare const reactFormatter: FormatterImplementation<ReactFunctionTypes, React.ReactElement<any, string | React.JSXElementConstructor<any>>, React.ReactNode>;
30
+ export declare function makeReactFormatter(richTextElements: RichTextFormattingMap<ReactFunctionTypes['hook']>): FormatterImplementation<ReactFunctionTypes, ReactIntlMessage, React.ReactNode>;
31
+ export declare const reactFormatter: FormatterImplementation<ReactFunctionTypes, ReactIntlMessage, React.ReactNode>;
@@ -48,15 +48,16 @@ var format_1 = require("../format");
48
48
  var h = React.createElement;
49
49
  exports.DEFAULT_REACT_RICH_TEXT_ELEMENTS = {
50
50
  $_: function () { return ''; },
51
- $b: function (content) { return h('strong', undefined, content); },
52
- $code: function (content) { return h('code', undefined, content); },
53
- $i: function (content) { return h('em', undefined, content); },
51
+ $b: function (content, key) { return h('strong', { key: key }, content); },
52
+ $i: function (content, key) { return h('em', { key: key }, content); },
53
+ $del: function (content, key) { return h('del', { key: key }, content); },
54
+ $code: function (content, key) { return h('code', { key: key }, content); },
54
55
  // $link will always be [href, <empty>, ...content]
55
- $link: function (_a) {
56
+ $link: function (_a, key) {
56
57
  var _b = __read(_a), href = _b[0], content = _b.slice(1);
57
- return h('a', { href: href }, content);
58
+ return h('a', { href: href, key: key }, content);
58
59
  },
59
- $p: function (content) { return h('p', undefined, content); },
60
+ $p: function (content, key) { return h('p', { key: key }, content); },
60
61
  };
61
62
  /**
62
63
  * Creates a new `FormatBuilder` class that constructs a React element tree
@@ -70,11 +71,12 @@ function createReactBuilder(richTextElements) {
70
71
  __extends(class_1, _super);
71
72
  function class_1() {
72
73
  var _this = _super.apply(this, __spreadArray([], __read(arguments), false)) || this;
74
+ _this._nodeKey = 0;
73
75
  _this.result = [];
74
76
  return _this;
75
77
  }
76
78
  class_1.prototype.pushRichTextTag = function (tag, children) {
77
- this.result.push(richTextElements[tag](children));
79
+ this.result.push(richTextElements[tag](children, "".concat(this._nodeKey++)));
78
80
  };
79
81
  class_1.prototype.pushLiteralText = function (text) {
80
82
  this.result.push(text);
@@ -67,7 +67,7 @@ var StringBuilder = /** @class */ (function (_super) {
67
67
  StringBuilder.prototype.pushObject = function (value) {
68
68
  // Objects are only included in the result if they specify a toString value directly.
69
69
  // Otherwise, they would be rendered as `[object Object]`, which is never helpful.
70
- if ('toString' in value) {
70
+ if (value != null && 'toString' in value) {
71
71
  this.result += value.toString();
72
72
  }
73
73
  };
package/dist/hash.d.ts ADDED
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Returns a consistent, short hash of the given key by first processing it through a hash digest,
3
+ * then encoding the first few bytes to base64.
4
+ *
5
+ * This function is specifically written to mirror the native backend hashing function used by
6
+ * `@discord/intl-loader-core`, to be able to hash names at runtime.
7
+ */
8
+ export declare function runtimeHashMessageKey(key: string): string;
package/dist/hash.js ADDED
@@ -0,0 +1,41 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.runtimeHashMessageKey = runtimeHashMessageKey;
4
+ var xxhash64_1 = require("@intrnl/xxhash64");
5
+ var BASE64_TABLE = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'.split('');
6
+ var IS_BIG_ENDIAN = (function () {
7
+ var array = new Uint8Array(4);
8
+ var view = new Uint32Array(array.buffer);
9
+ return !((view[0] = 1) & array[0]);
10
+ })();
11
+ function numberToBytes(number) {
12
+ number = BigInt(number);
13
+ var array = [];
14
+ var byteCount = Math.ceil(Math.floor(Math.log2(Number(number)) + 1) / 8);
15
+ for (var i = 0; i < byteCount; i++) {
16
+ array.unshift(Number((number >> BigInt(8 * i)) & BigInt(255)));
17
+ }
18
+ var bytes = new Uint8Array(array);
19
+ // The native `hashToMessageKey` always works in Big/Network Endian bytes, so this array
20
+ // needs to be converted to the same endianness to get the same base64 result.
21
+ return IS_BIG_ENDIAN ? bytes : bytes.reverse();
22
+ }
23
+ /**
24
+ * Returns a consistent, short hash of the given key by first processing it through a hash digest,
25
+ * then encoding the first few bytes to base64.
26
+ *
27
+ * This function is specifically written to mirror the native backend hashing function used by
28
+ * `@discord/intl-loader-core`, to be able to hash names at runtime.
29
+ */
30
+ function runtimeHashMessageKey(key) {
31
+ var hash = (0, xxhash64_1.hash)(key, 0);
32
+ var bytes = numberToBytes(hash);
33
+ return [
34
+ BASE64_TABLE[bytes[0] >> 2],
35
+ BASE64_TABLE[((bytes[0] & 0x03) << 4) | (bytes[1] >> 4)],
36
+ BASE64_TABLE[((bytes[1] & 0x0f) << 2) | (bytes[2] >> 6)],
37
+ BASE64_TABLE[bytes[2] & 0x3f],
38
+ BASE64_TABLE[bytes[3] >> 2],
39
+ BASE64_TABLE[((bytes[3] & 0x03) << 4) | (bytes[3] >> 4)],
40
+ ].join('');
41
+ }
package/dist/index.d.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  import * as React from 'react';
2
2
  import { RichTextNode } from './formatters';
3
3
  export * from './formatters';
4
+ export { FormatBuilder, FormatBuilderConstructor, bindFormatValues } from './format';
5
+ export { runtimeHashMessageKey } from './hash';
4
6
  export { IntlManager, DEFAULT_LOCALE, type FormatFunction } from './intl-manager';
5
7
  export { createLoader, loadAllMessagesInLocale, waitForAllDefaultIntlMessagesLoaded, MessageLoader, } from './message-loader';
6
8
  export type * from './types.d.ts';
package/dist/index.js CHANGED
@@ -14,8 +14,13 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
14
14
  for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
15
15
  };
16
16
  Object.defineProperty(exports, "__esModule", { value: true });
17
- exports.MessageLoader = exports.waitForAllDefaultIntlMessagesLoaded = exports.loadAllMessagesInLocale = exports.createLoader = exports.DEFAULT_LOCALE = exports.IntlManager = void 0;
17
+ exports.MessageLoader = exports.waitForAllDefaultIntlMessagesLoaded = exports.loadAllMessagesInLocale = exports.createLoader = exports.DEFAULT_LOCALE = exports.IntlManager = exports.runtimeHashMessageKey = exports.bindFormatValues = exports.FormatBuilder = void 0;
18
18
  __exportStar(require("./formatters"), exports);
19
+ var format_1 = require("./format");
20
+ Object.defineProperty(exports, "FormatBuilder", { enumerable: true, get: function () { return format_1.FormatBuilder; } });
21
+ Object.defineProperty(exports, "bindFormatValues", { enumerable: true, get: function () { return format_1.bindFormatValues; } });
22
+ var hash_1 = require("./hash");
23
+ Object.defineProperty(exports, "runtimeHashMessageKey", { enumerable: true, get: function () { return hash_1.runtimeHashMessageKey; } });
19
24
  var intl_manager_1 = require("./intl-manager");
20
25
  Object.defineProperty(exports, "IntlManager", { enumerable: true, get: function () { return intl_manager_1.IntlManager; } });
21
26
  Object.defineProperty(exports, "DEFAULT_LOCALE", { enumerable: true, get: function () { return intl_manager_1.DEFAULT_LOCALE; } });
@@ -7,7 +7,7 @@ import { FormatBuilderConstructor } from './format';
7
7
  * requested locale is not possible.
8
8
  */
9
9
  export declare const DEFAULT_LOCALE: string;
10
- type FormatterReturnType<F extends FormatterImplementation<any, any>> = F extends FormatterImplementation<any, infer Return> ? Return : never;
10
+ type FormatterReturnType<F extends FormatterImplementation<any, any>> = ReturnType<F['format']>;
11
11
  type FormatterFunctionTypes<F extends FormatterImplementation<any, any>> = F extends FormatterImplementation<infer Functions, any> ? Functions : never;
12
12
  export type FormatFunction<F extends FormatterImplementation<any, any>> = <T extends IntlMessageGetter>(this: IntlManager, message: T, values: RequiredFormatValues<T, FormatterFunctionTypes<F>>) => FormatterReturnType<F>;
13
13
  type ThisWithFormatters<This, T extends Record<string, FormatterImplementation<any, any>>> = This & {
@@ -45,6 +45,16 @@ export declare class IntlManager {
45
45
  * immediately return the plain string value of the message in the current locale.
46
46
  */
47
47
  string<T extends TypedIntlMessageGetter<undefined>>(message: T): string;
48
+ /**
49
+ * Return a raw string representing the syntax of the original message, as
50
+ * authored, with no values replaced. The result of this function could be
51
+ * written back to the definition file for the message and re-parsed to
52
+ * create an identical message to the original.
53
+ *
54
+ * This should rarely be necessary outside of sending raw messages to other
55
+ * applications that do their own message parsing.
56
+ */
57
+ reserialize<T extends IntlMessageGetter>(message: T): string;
48
58
  bindFormatValues<T>(Builder: FormatBuilderConstructor<T>, message: InternalIntlMessage, values: Record<string, any>): T[];
49
59
  }
50
60
  export {};
@@ -120,11 +120,28 @@ var IntlManager = /** @class */ (function () {
120
120
  * immediately return the plain string value of the message in the current locale.
121
121
  */
122
122
  IntlManager.prototype.string = function (message) {
123
- // @ts-expect-error Figure out how to make this typing exact. This
124
- // currently relies on the generic typing being sound enough to know that
125
- // the message can only contain a single static text node and no
126
- // placeholders or rich text.
127
- return message(this.currentLocale).ast[0].value;
123
+ var resolved = message(this.currentLocale);
124
+ if (resolved == null || resolved.ast.length === 0)
125
+ return '';
126
+ // TODO: Figure out how to make this typing exact. This currently relies on
127
+ // the generic typing being sound enough to know that the message can only
128
+ // contain a single static text node and no placeholders or rich text.
129
+ return resolved.ast[0].value;
130
+ };
131
+ /**
132
+ * Return a raw string representing the syntax of the original message, as
133
+ * authored, with no values replaced. The result of this function could be
134
+ * written back to the definition file for the message and re-parsed to
135
+ * create an identical message to the original.
136
+ *
137
+ * This should rarely be necessary outside of sending raw messages to other
138
+ * applications that do their own message parsing.
139
+ */
140
+ IntlManager.prototype.reserialize = function (message) {
141
+ var resolved = message(this.currentLocale);
142
+ if (typeof resolved === 'string')
143
+ return resolved;
144
+ return resolved.reserialize();
128
145
  };
129
146
  IntlManager.prototype.bindFormatValues = function (Builder, message, values) {
130
147
  return (0, format_1.bindFormatValues)(Builder, message.ast, [this.currentLocale, this.defaultLocale], this.intl.formatters, this.intl.formats, values);
package/dist/message.d.ts CHANGED
@@ -3,4 +3,9 @@ export declare class InternalIntlMessage {
3
3
  locale: string;
4
4
  ast: FormatJsNode[];
5
5
  constructor(messageOrAst: FormatJsNode[], locale: string);
6
+ /**
7
+ * Return a stringified serialization of this message's AST, with no
8
+ * formatting or values applied.
9
+ */
10
+ reserialize(): string;
6
11
  }
package/dist/message.js CHANGED
@@ -1,11 +1,186 @@
1
1
  "use strict";
2
+ var __values = (this && this.__values) || function(o) {
3
+ var s = typeof Symbol === "function" && Symbol.iterator, m = s && o[s], i = 0;
4
+ if (m) return m.call(o);
5
+ if (o && typeof o.length === "number") return {
6
+ next: function () {
7
+ if (o && i >= o.length) o = void 0;
8
+ return { value: o && o[i++], done: !o };
9
+ }
10
+ };
11
+ throw new TypeError(s ? "Object is not iterable." : "Symbol.iterator is not defined.");
12
+ };
13
+ var __read = (this && this.__read) || function (o, n) {
14
+ var m = typeof Symbol === "function" && o[Symbol.iterator];
15
+ if (!m) return o;
16
+ var i = m.call(o), r, ar = [], e;
17
+ try {
18
+ while ((n === void 0 || n-- > 0) && !(r = i.next()).done) ar.push(r.value);
19
+ }
20
+ catch (error) { e = { error: error }; }
21
+ finally {
22
+ try {
23
+ if (r && !r.done && (m = i["return"])) m.call(i);
24
+ }
25
+ finally { if (e) throw e.error; }
26
+ }
27
+ return ar;
28
+ };
2
29
  Object.defineProperty(exports, "__esModule", { value: true });
3
30
  exports.InternalIntlMessage = void 0;
31
+ var keyless_json_1 = require("./keyless-json");
4
32
  var InternalIntlMessage = /** @class */ (function () {
5
33
  function InternalIntlMessage(messageOrAst, locale) {
6
34
  this.locale = locale;
7
35
  this.ast = messageOrAst;
8
36
  }
37
+ /**
38
+ * Return a stringified serialization of this message's AST, with no
39
+ * formatting or values applied.
40
+ */
41
+ InternalIntlMessage.prototype.reserialize = function () {
42
+ var result = { value: '' };
43
+ serializeFormatJsAst(this.ast, result);
44
+ return result.value;
45
+ };
9
46
  return InternalIntlMessage;
10
47
  }());
11
48
  exports.InternalIntlMessage = InternalIntlMessage;
49
+ // Accepting an object as the `result` parameter lets the same string get passed around and
50
+ // appended, rather than creating a bunch of intermediate strings.
51
+ function serializeFormatJsAst(ast, result) {
52
+ var e_1, _a, e_2, _b, e_3, _c;
53
+ try {
54
+ for (var ast_1 = __values(ast), ast_1_1 = ast_1.next(); !ast_1_1.done; ast_1_1 = ast_1.next()) {
55
+ var node = ast_1_1.value;
56
+ switch (node.type) {
57
+ case keyless_json_1.FormatJsNodeType.Literal:
58
+ result.value += node.value;
59
+ return;
60
+ case keyless_json_1.FormatJsNodeType.Argument:
61
+ // Empties are an artifact of our parsing strategy, not necessary here.
62
+ if (result.value === '$_')
63
+ return;
64
+ result.value += '{' + node.value + '}';
65
+ return;
66
+ case keyless_json_1.FormatJsNodeType.Date:
67
+ result.value += '{' + node.value + ', date';
68
+ if (node.style != null) {
69
+ result.value += ', ' + node.style;
70
+ }
71
+ result.value += '}';
72
+ return;
73
+ case keyless_json_1.FormatJsNodeType.Time:
74
+ result.value += '{' + node.value + ', time';
75
+ if (node.style != null) {
76
+ result.value += ', ' + node.style;
77
+ }
78
+ result.value += '}';
79
+ return;
80
+ case keyless_json_1.FormatJsNodeType.Number:
81
+ result.value += '{' + node.value + ', number';
82
+ if (node.style != null) {
83
+ result.value += ', ' + node.style;
84
+ }
85
+ result.value += '}';
86
+ return;
87
+ case keyless_json_1.FormatJsNodeType.Plural: {
88
+ var pluralType = node.pluralType == 'ordinal' ? 'selectordinal' : 'plural';
89
+ result.value += '{' + node.value + ', ' + pluralType + ', ';
90
+ if (node.offset) {
91
+ result.value += 'offset:' + node.offset;
92
+ }
93
+ try {
94
+ for (var _d = (e_2 = void 0, __values(Object.entries(node.options))), _e = _d.next(); !_e.done; _e = _d.next()) {
95
+ var _f = __read(_e.value, 2), name = _f[0], arm = _f[1];
96
+ result.value += ' ' + name + ' {';
97
+ serializeFormatJsAst(arm.value, result);
98
+ result.value += '}';
99
+ }
100
+ }
101
+ catch (e_2_1) { e_2 = { error: e_2_1 }; }
102
+ finally {
103
+ try {
104
+ if (_e && !_e.done && (_b = _d.return)) _b.call(_d);
105
+ }
106
+ finally { if (e_2) throw e_2.error; }
107
+ }
108
+ return;
109
+ }
110
+ case keyless_json_1.FormatJsNodeType.Pound:
111
+ result.value += '#';
112
+ return;
113
+ case keyless_json_1.FormatJsNodeType.Select: {
114
+ result.value += '{' + node.value + ', select, ';
115
+ try {
116
+ for (var _g = (e_3 = void 0, __values(Object.entries(node.options))), _h = _g.next(); !_h.done; _h = _g.next()) {
117
+ var _j = __read(_h.value, 2), name = _j[0], arm = _j[1];
118
+ result.value += ' ' + name + ' {';
119
+ serializeFormatJsAst(arm.value, result);
120
+ result.value += '}';
121
+ }
122
+ }
123
+ catch (e_3_1) { e_3 = { error: e_3_1 }; }
124
+ finally {
125
+ try {
126
+ if (_h && !_h.done && (_c = _g.return)) _c.call(_g);
127
+ }
128
+ finally { if (e_3) throw e_3.error; }
129
+ }
130
+ return;
131
+ }
132
+ case keyless_json_1.FormatJsNodeType.Tag:
133
+ serializeFormatJsTag(node, result);
134
+ return;
135
+ }
136
+ }
137
+ }
138
+ catch (e_1_1) { e_1 = { error: e_1_1 }; }
139
+ finally {
140
+ try {
141
+ if (ast_1_1 && !ast_1_1.done && (_a = ast_1.return)) _a.call(ast_1);
142
+ }
143
+ finally { if (e_1) throw e_1.error; }
144
+ }
145
+ }
146
+ function serializeFormatJsTag(node, result) {
147
+ switch (node.value) {
148
+ case '$b':
149
+ result.value += '**';
150
+ serializeFormatJsAst(node.children, result);
151
+ result.value += '**';
152
+ return;
153
+ case '$i':
154
+ result.value += '*';
155
+ serializeFormatJsAst(node.children, result);
156
+ result.value += '*';
157
+ return;
158
+ case '$code':
159
+ result.value += '`';
160
+ serializeFormatJsAst(node.children, result);
161
+ result.value += '`';
162
+ return;
163
+ case '$p':
164
+ serializeFormatJsAst(node.children, result);
165
+ result.value += '\n\n';
166
+ return;
167
+ case '$link':
168
+ // The target is the first child of the link. We don't have to care if it's a placeholder
169
+ // or not, because the serialization will automatically remove the extra empty.
170
+ var _a = __read(node.children), target = _a[0], children = _a.slice(1);
171
+ result.value += '[';
172
+ serializeFormatJsAst(children, result);
173
+ result.value += '](';
174
+ if (target != null) {
175
+ serializeFormatJsAst([target], result);
176
+ }
177
+ result.value += ')';
178
+ return;
179
+ default:
180
+ // Any other tag name is a hook, which just adds the `$[` on a link tag.
181
+ result.value += '$[';
182
+ serializeFormatJsAst(node.children, result);
183
+ result.value += '](' + node.value + ')';
184
+ return;
185
+ }
186
+ }
package/dist/types.d.ts CHANGED
@@ -197,9 +197,9 @@ export interface FunctionTypeMap {
197
197
  handler: any;
198
198
  }
199
199
 
200
- export interface FunctionTypes<Result, HandlerType = (content: Result[]) => Result | Result[]> {
201
- link: undefined | ((content: Result[]) => Result | Result[]);
202
- hook: undefined | ((content: Result[]) => Result | Result[]);
200
+ export interface FunctionTypes<Result, HandlerType = () => void> {
201
+ link: undefined | ((content: Result[], key: string) => Result | Result[]);
202
+ hook: undefined | ((content: Result[], key: string) => Result | Result[]);
203
203
  handler: undefined | HandlerType;
204
204
  }
205
205
 
@@ -276,6 +276,7 @@ export interface RichTextFormattingMap<T = any> {
276
276
  $p: T;
277
277
  $link: T;
278
278
  $code: T;
279
+ $del: T;
279
280
  }
280
281
 
281
282
  export type RichTextTagNames = keyof RichTextFormattingMap;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@discord/intl",
3
- "version": "0.2.1-rc.1",
3
+ "version": "0.2.2-canary.6898c23",
4
4
  "license": "MIT",
5
5
  "description": "Client runtime for managing messages and translations in a React project.",
6
6
  "main": "./dist/index.js",
@@ -15,7 +15,9 @@
15
15
  "src"
16
16
  ],
17
17
  "dependencies": {
18
+ "@formatjs/icu-skeleton-parser": "1.8.2",
18
19
  "@formatjs/intl": "^2.10.1",
20
+ "@intrnl/xxhash64": "^0.1.2",
19
21
  "intl-messageformat": "^10.5.11"
20
22
  },
21
23
  "devDependencies": {
@@ -25,8 +27,8 @@
25
27
  },
26
28
  "peerDependencies": {
27
29
  "@rspack/core": "0.x || 1.x",
28
- "webpack": "^5.0.0",
29
- "react": "> 16"
30
+ "react": "> 16",
31
+ "webpack": "^5.0.0"
30
32
  },
31
33
  "peerDependenciesMeta": {
32
34
  "@rspack/core": {
package/src/format.ts CHANGED
@@ -20,6 +20,11 @@
20
20
  import { FormatJsNode, FormatJsNodeType } from './keyless-json';
21
21
  import { Formats, Formatters, MissingValueError } from 'intl-messageformat';
22
22
  import { RichTextTagNames } from './types';
23
+ import {
24
+ parseDateTimeSkeleton,
25
+ parseNumberSkeleton,
26
+ parseNumberSkeletonFromString,
27
+ } from '@formatjs/icu-skeleton-parser';
23
28
 
24
29
  /**
25
30
  * Returns true if the tag name should be considered a rich text tag that
@@ -96,10 +101,12 @@ export function bindFormatValuesWithBuilder<T, Builder extends FormatBuilder<T>>
96
101
  case FormatJsNodeType.Date: {
97
102
  // Distinct from FormatJS: We don't currently parse the skeleton ahead of time in the AST,
98
103
  // 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();
104
+ const style =
105
+ node.style in formats.date
106
+ ? formats.date[node.style]
107
+ : node.style != null
108
+ ? parseDateTimeSkeleton(node.style)
109
+ : undefined;
103
110
  // @ts-expect-error Cast string values to dates properly.
104
111
  builder.pushLiteralText(formatters.getDateTimeFormat(locales, style).format(value));
105
112
  break;
@@ -107,10 +114,12 @@ export function bindFormatValuesWithBuilder<T, Builder extends FormatBuilder<T>>
107
114
  case FormatJsNodeType.Time: {
108
115
  // Distinct from FormatJS: We don't currently parse the skeleton ahead of time in the AST,
109
116
  // 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();
117
+ const style =
118
+ node.style in formats.time
119
+ ? formats.time[node.style]
120
+ : node.style != null
121
+ ? parseDateTimeSkeleton(node.style)
122
+ : undefined; // TODO: parseSkeleton();
114
123
  builder.pushLiteralText(
115
124
  // @ts-expect-error Cast string values to dates properly.
116
125
  formatters.getDateTimeFormat(locales, style).format(value),
@@ -120,14 +129,15 @@ export function bindFormatValuesWithBuilder<T, Builder extends FormatBuilder<T>>
120
129
  case FormatJsNodeType.Number: {
121
130
  // Distinct from FormatJS: We don't currently parse the skeleton ahead of time in the AST,
122
131
  // 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);
132
+ const style =
133
+ node.style in formats.number
134
+ ? formats.number[node.style]
135
+ : node.style != null
136
+ ? parseNumberSkeleton(parseNumberSkeletonFromString(node.style))
137
+ : undefined;
138
+ const scaledValue =
139
+ // @ts-expect-error This is a weird cast that's not accurate, but works in the short term.
140
+ typeof value !== 'number' ? (value as number) : ((value as number) * style?.scale ?? 1);
131
141
  builder.pushLiteralText(formatters.getNumberFormat(locales, style).format(scaledValue));
132
142
  break;
133
143
  }
@@ -12,7 +12,7 @@ import { FormatBuilder } from '../format';
12
12
  * Types for formatting functions when calling `formatToParts`, ensuring the
13
13
  * functions yield value AST nodes.
14
14
  */
15
- export type AstFunctionTypes = FunctionTypes<RichTextNode>;
15
+ export type AstFunctionTypes = FunctionTypes<RichTextNode, object>;
16
16
 
17
17
  // This structure aims to match `simple-markdown`'s AST, but that type is very
18
18
  // loosely defined as just "type" and "arbitrary map of content". This is more
@@ -20,8 +20,9 @@ export type AstFunctionTypes = FunctionTypes<RichTextNode>;
20
20
  export enum RichTextNodeType {
21
21
  Text = 'text',
22
22
  Strong = 'strong',
23
- Emphasis = 'emphasis',
24
- Code = 'code',
23
+ Emphasis = 'em',
24
+ Strikethrough = 's',
25
+ Code = 'inlineCode',
25
26
  Link = 'link',
26
27
  Paragraph = 'paragraph',
27
28
  /**
@@ -36,15 +37,6 @@ export enum RichTextNodeType {
36
37
  Object = 'object',
37
38
  }
38
39
 
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
40
  interface RichTextNodeBase<ContentType> {
49
41
  type: RichTextNodeType;
50
42
  content: ContentType;
@@ -58,6 +50,9 @@ interface RichTextStrongNode extends RichTextNodeBase<RichTextNode[]> {
58
50
  interface RichTextEmphasisNode extends RichTextNodeBase<RichTextNode[]> {
59
51
  type: RichTextNodeType.Emphasis;
60
52
  }
53
+ interface RichTextStrikethroughNode extends RichTextNodeBase<RichTextNode[]> {
54
+ type: RichTextNodeType.Strikethrough;
55
+ }
61
56
  interface RichTextCodeNode extends RichTextNodeBase<RichTextNode[]> {
62
57
  type: RichTextNodeType.Code;
63
58
  }
@@ -76,6 +71,7 @@ export type RichTextNode =
76
71
  | RichTextTextNode
77
72
  | RichTextStrongNode
78
73
  | RichTextEmphasisNode
74
+ | RichTextStrikethroughNode
79
75
  | RichTextCodeNode
80
76
  | RichTextParagraphNode
81
77
  | RichTextLinkNode
@@ -84,8 +80,9 @@ export type RichTextNode =
84
80
  const AST_RICH_TEXT_ELEMENTS: RichTextFormattingMap<AstFunctionTypes['hook']> = {
85
81
  $_: () => ({ type: RichTextNodeType.Text, content: '' }),
86
82
  $b: (content) => ({ type: RichTextNodeType.Strong, content }),
87
- $code: (content) => ({ type: RichTextNodeType.Code, content }),
88
83
  $i: (content) => ({ type: RichTextNodeType.Emphasis, content }),
84
+ $del: (content) => ({ type: RichTextNodeType.Strikethrough, content }),
85
+ $code: (content) => ({ type: RichTextNodeType.Code, content }),
89
86
  $link: ([target, ...content]) => ({
90
87
  type: RichTextNodeType.Link,
91
88
  target: target.content as string,
@@ -101,7 +98,7 @@ class AstBuilder extends FormatBuilder<RichTextNode> {
101
98
  if (!(tag in AST_RICH_TEXT_ELEMENTS)) {
102
99
  throw `${tag} is not a known rich text formatting tag`;
103
100
  }
104
- const result = AST_RICH_TEXT_ELEMENTS[tag](children);
101
+ const result = AST_RICH_TEXT_ELEMENTS[tag](children, '');
105
102
  if (Array.isArray(result)) {
106
103
  this.result.push(...result);
107
104
  } else {
@@ -111,7 +108,7 @@ class AstBuilder extends FormatBuilder<RichTextNode> {
111
108
 
112
109
  pushLiteralText(text: string) {
113
110
  const last = this.result[this.result.length - 1];
114
- if (last.type === RichTextNodeType.Text) {
111
+ if (last != null && last.type === RichTextNodeType.Text) {
115
112
  last.content += text;
116
113
  } else {
117
114
  this.result.push({ type: RichTextNodeType.Text, content: text });
@@ -21,8 +21,9 @@ export type MarkdownFunctionTypes = FunctionTypes<string>;
21
21
  const MARKDOWN_RICH_TEXT_ELEMENTS: RichTextFormattingMap<MarkdownFunctionTypes['hook']> = {
22
22
  $_: () => '',
23
23
  $b: (content) => '**' + content.join('') + '**',
24
- $code: (content) => '`' + content.join('') + '`',
25
24
  $i: (content) => '*' + content.join('') + '*',
25
+ $del: (content) => '~~' + content.join('') + '~~',
26
+ $code: (content) => '`' + content.join('') + '`',
26
27
  $link: ([target, ...content]) => '[' + content.join('') + '](' + target + ')',
27
28
  $p: (content) => content.join('') + '\n\n',
28
29
  };
@@ -31,7 +32,7 @@ class MarkdownBuilder extends StringBuilder {
31
32
  result: string = '';
32
33
 
33
34
  pushRichTextTag(tag: RichTextTagNames, children: string[]) {
34
- this.result += MARKDOWN_RICH_TEXT_ELEMENTS[tag](children);
35
+ this.result += MARKDOWN_RICH_TEXT_ELEMENTS[tag](children, '');
35
36
  }
36
37
  }
37
38
 
@@ -28,12 +28,13 @@ export type ReactFunctionTypes = FunctionTypes<
28
28
  const h = React.createElement;
29
29
  export const DEFAULT_REACT_RICH_TEXT_ELEMENTS: RichTextFormattingMap<ReactFunctionTypes['hook']> = {
30
30
  $_: () => '',
31
- $b: (content) => h('strong', undefined, content),
32
- $code: (content) => h('code', undefined, content),
33
- $i: (content) => h('em', undefined, content),
31
+ $b: (content, key) => h('strong', { key }, content),
32
+ $i: (content, key) => h('em', { key }, content),
33
+ $del: (content, key) => h('del', { key }, content),
34
+ $code: (content, key) => h('code', { key }, content),
34
35
  // $link will always be [href, <empty>, ...content]
35
- $link: ([href, ...content]) => h('a', { href }, content),
36
- $p: (content) => h('p', undefined, content),
36
+ $link: ([href, ...content], key) => h('a', { href, key }, content),
37
+ $p: (content, key) => h('p', { key }, content),
37
38
  };
38
39
 
39
40
  /**
@@ -47,10 +48,11 @@ function createReactBuilder(richTextElements: RichTextFormattingMap<ReactFunctio
47
48
  new (): FormatBuilder<React.ReactNode>;
48
49
  } {
49
50
  return class extends FormatBuilder<React.ReactNode> {
51
+ _nodeKey: number = 0;
50
52
  result: React.ReactNode[] = [];
51
53
 
52
54
  pushRichTextTag(tag: RichTextTagNames, children: React.ReactNode[]) {
53
- this.result.push(richTextElements[tag](children));
55
+ this.result.push(richTextElements[tag](children, `${this._nodeKey++}`));
54
56
  }
55
57
 
56
58
  pushLiteralText(text: string) {
@@ -69,18 +71,23 @@ function createReactBuilder(richTextElements: RichTextFormattingMap<ReactFunctio
69
71
  };
70
72
  }
71
73
 
74
+ export type ReactIntlMessage = React.ReactElement<
75
+ {},
76
+ typeof React.Fragment & { __brand: 'discord-intl' }
77
+ >;
78
+
72
79
  export function formatReact(
73
80
  this: IntlManager,
74
81
  message: AnyIntlMessage,
75
82
  values: object,
76
83
  Builder: FormatBuilderConstructor<React.ReactElement>,
77
- ): React.ReactElement {
84
+ ): ReactIntlMessage {
78
85
  if (typeof message === 'string') {
79
- return React.createElement(React.Fragment, undefined, message);
86
+ return React.createElement(React.Fragment, undefined, message) as ReactIntlMessage;
80
87
  }
81
88
 
82
89
  const parts = this.bindFormatValues(Builder, message, values);
83
- return React.createElement(React.Fragment, undefined, parts);
90
+ return React.createElement(React.Fragment, undefined, parts) as ReactIntlMessage;
84
91
  }
85
92
 
86
93
  /**
@@ -90,7 +97,7 @@ export function formatReact(
90
97
  */
91
98
  export function makeReactFormatter(
92
99
  richTextElements: RichTextFormattingMap<ReactFunctionTypes['hook']>,
93
- ): FormatterImplementation<ReactFunctionTypes, React.ReactElement, React.ReactNode> {
100
+ ): FormatterImplementation<ReactFunctionTypes, ReactIntlMessage, React.ReactNode> {
94
101
  return {
95
102
  format: formatReact,
96
103
  builder: createReactBuilder(richTextElements),
@@ -34,7 +34,7 @@ export class StringBuilder extends FormatBuilder<string> {
34
34
  pushObject(value: object) {
35
35
  // Objects are only included in the result if they specify a toString value directly.
36
36
  // Otherwise, they would be rendered as `[object Object]`, which is never helpful.
37
- if ('toString' in value) {
37
+ if (value != null && 'toString' in value) {
38
38
  this.result += value.toString();
39
39
  }
40
40
  }
package/src/hash.ts ADDED
@@ -0,0 +1,42 @@
1
+ import { hash as h64 } from '@intrnl/xxhash64';
2
+
3
+ const BASE64_TABLE = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'.split('');
4
+ const IS_BIG_ENDIAN = (() => {
5
+ const array = new Uint8Array(4);
6
+ const view = new Uint32Array(array.buffer);
7
+ return !((view[0] = 1) & array[0]);
8
+ })();
9
+
10
+ function numberToBytes(number) {
11
+ number = BigInt(number);
12
+ const array = [];
13
+ const byteCount = Math.ceil(Math.floor(Math.log2(Number(number)) + 1) / 8);
14
+ for (let i = 0; i < byteCount; i++) {
15
+ array.unshift(Number((number >> BigInt(8 * i)) & BigInt(255)));
16
+ }
17
+
18
+ const bytes = new Uint8Array(array);
19
+ // The native `hashToMessageKey` always works in Big/Network Endian bytes, so this array
20
+ // needs to be converted to the same endianness to get the same base64 result.
21
+ return IS_BIG_ENDIAN ? bytes : bytes.reverse();
22
+ }
23
+
24
+ /**
25
+ * Returns a consistent, short hash of the given key by first processing it through a hash digest,
26
+ * then encoding the first few bytes to base64.
27
+ *
28
+ * This function is specifically written to mirror the native backend hashing function used by
29
+ * `@discord/intl-loader-core`, to be able to hash names at runtime.
30
+ */
31
+ export function runtimeHashMessageKey(key: string): string {
32
+ const hash = h64(key, 0);
33
+ const bytes = numberToBytes(hash);
34
+ return [
35
+ BASE64_TABLE[bytes[0] >> 2],
36
+ BASE64_TABLE[((bytes[0] & 0x03) << 4) | (bytes[1] >> 4)],
37
+ BASE64_TABLE[((bytes[1] & 0x0f) << 2) | (bytes[2] >> 6)],
38
+ BASE64_TABLE[bytes[2] & 0x3f],
39
+ BASE64_TABLE[bytes[3] >> 2],
40
+ BASE64_TABLE[((bytes[3] & 0x03) << 4) | (bytes[3] >> 4)],
41
+ ].join('');
42
+ }
package/src/index.ts CHANGED
@@ -2,6 +2,8 @@ import * as React from 'react';
2
2
  import { RichTextNode } from './formatters';
3
3
 
4
4
  export * from './formatters';
5
+ export { FormatBuilder, FormatBuilderConstructor, bindFormatValues } from './format';
6
+ export { runtimeHashMessageKey } from './hash';
5
7
  export { IntlManager, DEFAULT_LOCALE, type FormatFunction } from './intl-manager';
6
8
  export {
7
9
  createLoader,
@@ -26,6 +28,7 @@ export type * from './types.d.ts';
26
28
  * intl message, even if the actual value comes from elsewhere (like a
27
29
  * user-generated string).
28
30
  */
31
+ // TODO: Replace this with ReactIntlMessage from `formatters/react`.
29
32
  export type ReactIntlMessage = string | React.ReactElement;
30
33
 
31
34
  /**
@@ -1,8 +1,6 @@
1
1
  import { createIntl, IntlShape } from '@formatjs/intl';
2
2
  import { Formats, Formatters, IntlMessageFormat } from 'intl-messageformat';
3
3
 
4
- import { LocaleId, LocaleImportMap, MessageLoader } from './message-loader';
5
-
6
4
  import type {
7
5
  FormatterImplementation,
8
6
  IntlMessageGetter,
@@ -11,6 +9,7 @@ import type {
11
9
  } from './types';
12
10
  import { InternalIntlMessage } from './message';
13
11
  import { bindFormatValues, FormatBuilderConstructor } from './format';
12
+ import { FormatJsLiteral } from './keyless-json';
14
13
 
15
14
  /**
16
15
  * Fallback locale used for all internationalization when an operation in the
@@ -18,8 +17,7 @@ import { bindFormatValues, FormatBuilderConstructor } from './format';
18
17
  */
19
18
  export const DEFAULT_LOCALE: string = 'en-US';
20
19
 
21
- type FormatterReturnType<F extends FormatterImplementation<any, any>> =
22
- F extends FormatterImplementation<any, infer Return> ? Return : never;
20
+ type FormatterReturnType<F extends FormatterImplementation<any, any>> = ReturnType<F['format']>;
23
21
 
24
22
  type FormatterFunctionTypes<F extends FormatterImplementation<any, any>> =
25
23
  F extends FormatterImplementation<infer Functions, any> ? Functions : never;
@@ -116,11 +114,28 @@ export class IntlManager {
116
114
  * immediately return the plain string value of the message in the current locale.
117
115
  */
118
116
  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;
117
+ const resolved = message(this.currentLocale);
118
+ if (resolved == null || resolved.ast.length === 0) return '';
119
+
120
+ // TODO: Figure out how to make this typing exact. This currently relies on
121
+ // the generic typing being sound enough to know that the message can only
122
+ // contain a single static text node and no placeholders or rich text.
123
+ return (resolved.ast[0] as FormatJsLiteral).value;
124
+ }
125
+
126
+ /**
127
+ * Return a raw string representing the syntax of the original message, as
128
+ * authored, with no values replaced. The result of this function could be
129
+ * written back to the definition file for the message and re-parsed to
130
+ * create an identical message to the original.
131
+ *
132
+ * This should rarely be necessary outside of sending raw messages to other
133
+ * applications that do their own message parsing.
134
+ */
135
+ reserialize<T extends IntlMessageGetter>(message: T): string {
136
+ const resolved = message(this.currentLocale);
137
+ if (typeof resolved === 'string') return resolved;
138
+ return resolved.reserialize();
124
139
  }
125
140
 
126
141
  bindFormatValues<T>(
package/src/message.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { FormatJsNode } from './keyless-json';
1
+ import { FormatJsNode, FormatJsNodeType, FormatJsTag } from './keyless-json';
2
2
 
3
3
  export class InternalIntlMessage {
4
4
  locale: string;
@@ -8,4 +8,122 @@ export class InternalIntlMessage {
8
8
  this.locale = locale;
9
9
  this.ast = messageOrAst;
10
10
  }
11
+
12
+ /**
13
+ * Return a stringified serialization of this message's AST, with no
14
+ * formatting or values applied.
15
+ */
16
+ reserialize(): string {
17
+ const result = { value: '' };
18
+ serializeFormatJsAst(this.ast, result);
19
+ return result.value;
20
+ }
21
+ }
22
+
23
+ // Accepting an object as the `result` parameter lets the same string get passed around and
24
+ // appended, rather than creating a bunch of intermediate strings.
25
+ function serializeFormatJsAst(ast: FormatJsNode[], result: { value: string }) {
26
+ for (const node of ast) {
27
+ switch (node.type) {
28
+ case FormatJsNodeType.Literal:
29
+ result.value += node.value;
30
+ return;
31
+ case FormatJsNodeType.Argument:
32
+ // Empties are an artifact of our parsing strategy, not necessary here.
33
+ if (result.value === '$_') return;
34
+ result.value += '{' + node.value + '}';
35
+ return;
36
+ case FormatJsNodeType.Date:
37
+ result.value += '{' + node.value + ', date';
38
+ if (node.style != null) {
39
+ result.value += ', ' + node.style;
40
+ }
41
+ result.value += '}';
42
+ return;
43
+ case FormatJsNodeType.Time:
44
+ result.value += '{' + node.value + ', time';
45
+ if (node.style != null) {
46
+ result.value += ', ' + node.style;
47
+ }
48
+ result.value += '}';
49
+ return;
50
+ case FormatJsNodeType.Number:
51
+ result.value += '{' + node.value + ', number';
52
+ if (node.style != null) {
53
+ result.value += ', ' + node.style;
54
+ }
55
+ result.value += '}';
56
+ return;
57
+ case FormatJsNodeType.Plural: {
58
+ const pluralType = node.pluralType == 'ordinal' ? 'selectordinal' : 'plural';
59
+ result.value += '{' + node.value + ', ' + pluralType + ', ';
60
+ if (node.offset) {
61
+ result.value += 'offset:' + node.offset;
62
+ }
63
+ for (const [name, arm] of Object.entries(node.options)) {
64
+ result.value += ' ' + name + ' {';
65
+ serializeFormatJsAst(arm.value, result);
66
+ result.value += '}';
67
+ }
68
+ return;
69
+ }
70
+ case FormatJsNodeType.Pound:
71
+ result.value += '#';
72
+ return;
73
+ case FormatJsNodeType.Select: {
74
+ result.value += '{' + node.value + ', select, ';
75
+ for (const [name, arm] of Object.entries(node.options)) {
76
+ result.value += ' ' + name + ' {';
77
+ serializeFormatJsAst(arm.value, result);
78
+ result.value += '}';
79
+ }
80
+ return;
81
+ }
82
+ case FormatJsNodeType.Tag:
83
+ serializeFormatJsTag(node, result);
84
+ return;
85
+ }
86
+ }
87
+ }
88
+
89
+ function serializeFormatJsTag(node: FormatJsTag, result: { value: string }) {
90
+ switch (node.value) {
91
+ case '$b':
92
+ result.value += '**';
93
+ serializeFormatJsAst(node.children, result);
94
+ result.value += '**';
95
+ return;
96
+ case '$i':
97
+ result.value += '*';
98
+ serializeFormatJsAst(node.children, result);
99
+ result.value += '*';
100
+ return;
101
+ case '$code':
102
+ result.value += '`';
103
+ serializeFormatJsAst(node.children, result);
104
+ result.value += '`';
105
+ return;
106
+ case '$p':
107
+ serializeFormatJsAst(node.children, result);
108
+ result.value += '\n\n';
109
+ return;
110
+ case '$link':
111
+ // The target is the first child of the link. We don't have to care if it's a placeholder
112
+ // or not, because the serialization will automatically remove the extra empty.
113
+ const [target, ...children] = node.children;
114
+ result.value += '[';
115
+ serializeFormatJsAst(children, result);
116
+ result.value += '](';
117
+ if (target != null) {
118
+ serializeFormatJsAst([target], result);
119
+ }
120
+ result.value += ')';
121
+ return;
122
+ default:
123
+ // Any other tag name is a hook, which just adds the `$[` on a link tag.
124
+ result.value += '$[';
125
+ serializeFormatJsAst(node.children, result);
126
+ result.value += '](' + node.value + ')';
127
+ return;
128
+ }
11
129
  }
package/src/types.d.ts CHANGED
@@ -197,9 +197,9 @@ export interface FunctionTypeMap {
197
197
  handler: any;
198
198
  }
199
199
 
200
- export interface FunctionTypes<Result, HandlerType = (content: Result[]) => Result | Result[]> {
201
- link: undefined | ((content: Result[]) => Result | Result[]);
202
- hook: undefined | ((content: Result[]) => Result | Result[]);
200
+ export interface FunctionTypes<Result, HandlerType = () => void> {
201
+ link: undefined | ((content: Result[], key: string) => Result | Result[]);
202
+ hook: undefined | ((content: Result[], key: string) => Result | Result[]);
203
203
  handler: undefined | HandlerType;
204
204
  }
205
205
 
@@ -276,6 +276,7 @@ export interface RichTextFormattingMap<T = any> {
276
276
  $p: T;
277
277
  $link: T;
278
278
  $code: T;
279
+ $del: T;
279
280
  }
280
281
 
281
282
  export type RichTextTagNames = keyof RichTextFormattingMap;