intl-messageformat 7.8.4 → 8.2.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/src/formatters.ts CHANGED
@@ -12,7 +12,14 @@ import {
12
12
  isTimeElement,
13
13
  MessageFormatElement,
14
14
  parseDateTimeSkeleton,
15
+ isTagElement,
15
16
  } from 'intl-messageformat-parser';
17
+ import {
18
+ MissingValueError,
19
+ InvalidValueError,
20
+ ErrorCode,
21
+ FormatError,
22
+ } from './error';
16
23
 
17
24
  export interface Formats {
18
25
  number: Record<string, Intl.NumberFormatOptions>;
@@ -40,7 +47,7 @@ export interface Formatters {
40
47
 
41
48
  export const enum PART_TYPE {
42
49
  literal,
43
- argument,
50
+ object,
44
51
  }
45
52
 
46
53
  export interface LiteralPart {
@@ -48,24 +55,18 @@ export interface LiteralPart {
48
55
  value: string;
49
56
  }
50
57
 
51
- export interface ArgumentPart {
52
- type: PART_TYPE.argument;
53
- value: any;
58
+ export interface ObjectPart<T = any> {
59
+ type: PART_TYPE.object;
60
+ value: T;
54
61
  }
55
62
 
56
- export type MessageFormatPart = LiteralPart | ArgumentPart;
63
+ export type MessageFormatPart<T> = LiteralPart | ObjectPart<T>;
57
64
 
58
65
  export type PrimitiveType = string | number | boolean | null | undefined | Date;
59
66
 
60
- class FormatError extends Error {
61
- public readonly variableId?: string;
62
- constructor(msg?: string, variableId?: string) {
63
- super(msg);
64
- this.variableId = variableId;
65
- }
66
- }
67
-
68
- function mergeLiteral(parts: MessageFormatPart[]): MessageFormatPart[] {
67
+ function mergeLiteral<T>(
68
+ parts: MessageFormatPart<T>[]
69
+ ): MessageFormatPart<T>[] {
69
70
  if (parts.length < 2) {
70
71
  return parts;
71
72
  }
@@ -81,20 +82,26 @@ function mergeLiteral(parts: MessageFormatPart[]): MessageFormatPart[] {
81
82
  lastPart.value += part.value;
82
83
  }
83
84
  return all;
84
- }, [] as MessageFormatPart[]);
85
+ }, [] as MessageFormatPart<T>[]);
86
+ }
87
+
88
+ function isFormatXMLElementFn<T>(
89
+ el: PrimitiveType | T | FormatXMLElementFn<T>
90
+ ): el is FormatXMLElementFn<T> {
91
+ return typeof el === 'function';
85
92
  }
86
93
 
87
94
  // TODO(skeleton): add skeleton support
88
- export function formatToParts(
95
+ export function formatToParts<T>(
89
96
  els: MessageFormatElement[],
90
97
  locales: string | string[],
91
98
  formatters: Formatters,
92
99
  formats: Formats,
93
- values?: Record<string, any>,
100
+ values?: Record<string, PrimitiveType | T | FormatXMLElementFn<T>>,
94
101
  currentPluralValue?: number,
95
102
  // For debugging
96
103
  originalMessage?: string
97
- ): MessageFormatPart[] {
104
+ ): MessageFormatPart<T>[] {
98
105
  // Hot path for straight simple msg translations
99
106
  if (els.length === 1 && isLiteralElement(els[0])) {
100
107
  return [
@@ -104,7 +111,7 @@ export function formatToParts(
104
111
  },
105
112
  ];
106
113
  }
107
- const result: MessageFormatPart[] = [];
114
+ const result: MessageFormatPart<T>[] = [];
108
115
  for (const el of els) {
109
116
  // Exit early for string parts.
110
117
  if (isLiteralElement(el)) {
@@ -130,9 +137,7 @@ export function formatToParts(
130
137
 
131
138
  // Enforce that all required values are provided by the caller.
132
139
  if (!(values && varName in values)) {
133
- throw new FormatError(
134
- `The intl string context variable "${varName}" was not provided to the string "${originalMessage}"`
135
- );
140
+ throw new MissingValueError(varName, originalMessage);
136
141
  }
137
142
 
138
143
  let value = values[varName];
@@ -144,9 +149,9 @@ export function formatToParts(
144
149
  : '';
145
150
  }
146
151
  result.push({
147
- type: PART_TYPE.argument,
152
+ type: typeof value === 'string' ? PART_TYPE.literal : PART_TYPE.object,
148
153
  value,
149
- });
154
+ } as ObjectPart<T>);
150
155
  continue;
151
156
  }
152
157
 
@@ -194,14 +199,39 @@ export function formatToParts(
194
199
  });
195
200
  continue;
196
201
  }
202
+ if (isTagElement(el)) {
203
+ const {children, value} = el;
204
+ const formatFn = values[value];
205
+ if (!isFormatXMLElementFn<T>(formatFn)) {
206
+ throw new TypeError(`Value for "${value}" must be a function`);
207
+ }
208
+ const parts = formatToParts<T>(
209
+ children,
210
+ locales,
211
+ formatters,
212
+ formats,
213
+ values
214
+ );
215
+ let chunks = formatFn(...parts.map(p => p.value));
216
+ if (!Array.isArray(chunks)) {
217
+ chunks = [chunks];
218
+ }
219
+ result.push(
220
+ ...chunks.map(
221
+ (c): MessageFormatPart<T> => {
222
+ return {
223
+ type:
224
+ typeof c === 'string' ? PART_TYPE.literal : PART_TYPE.object,
225
+ value: c,
226
+ } as MessageFormatPart<T>;
227
+ }
228
+ )
229
+ );
230
+ }
197
231
  if (isSelectElement(el)) {
198
232
  const opt = el.options[value as string] || el.options.other;
199
233
  if (!opt) {
200
- throw new RangeError(
201
- `Invalid values for "${
202
- el.value
203
- }": "${value}". Options are "${Object.keys(el.options).join('", "')}"`
204
- );
234
+ throw new InvalidValueError(el.value, value, Object.keys(el.options));
205
235
  }
206
236
  result.push(
207
237
  ...formatToParts(opt.value, locales, formatters, formats, values)
@@ -212,9 +242,12 @@ export function formatToParts(
212
242
  let opt = el.options[`=${value}`];
213
243
  if (!opt) {
214
244
  if (!Intl.PluralRules) {
215
- throw new FormatError(`Intl.PluralRules is not available in this environment.
245
+ throw new FormatError(
246
+ `Intl.PluralRules is not available in this environment.
216
247
  Try polyfilling it using "@formatjs/intl-pluralrules"
217
- `);
248
+ `,
249
+ ErrorCode.MISSING_INTL_API
250
+ );
218
251
  }
219
252
  const rule = formatters
220
253
  .getPluralRules(locales, {type: el.pluralType})
@@ -222,11 +255,7 @@ Try polyfilling it using "@formatjs/intl-pluralrules"
222
255
  opt = el.options[rule] || el.options.other;
223
256
  }
224
257
  if (!opt) {
225
- throw new RangeError(
226
- `Invalid values for "${
227
- el.value
228
- }": "${value}". Options are "${Object.keys(el.options).join('", "')}"`
229
- );
258
+ throw new InvalidValueError(el.value, value, Object.keys(el.options));
230
259
  }
231
260
  result.push(
232
261
  ...formatToParts(
@@ -235,7 +264,7 @@ Try polyfilling it using "@formatjs/intl-pluralrules"
235
264
  formatters,
236
265
  formats,
237
266
  values,
238
- value - (el.offset || 0)
267
+ (value as number) - (el.offset || 0)
239
268
  )
240
269
  );
241
270
  continue;
@@ -244,211 +273,6 @@ Try polyfilling it using "@formatjs/intl-pluralrules"
244
273
  return mergeLiteral(result);
245
274
  }
246
275
 
247
- export function formatToString(
248
- els: MessageFormatElement[],
249
- locales: string | string[],
250
- formatters: Formatters,
251
- formats: Formats,
252
- values?: Record<string, PrimitiveType>,
253
- // For debugging
254
- originalMessage?: string
255
- ): string {
256
- const parts = formatToParts(
257
- els,
258
- locales,
259
- formatters,
260
- formats,
261
- values,
262
- undefined,
263
- originalMessage
264
- );
265
- // Hot path for straight simple msg translations
266
- if (parts.length === 1) {
267
- return parts[0].value;
268
- }
269
- return parts.reduce((all, part) => (all += part.value), '');
270
- }
271
-
272
- export type FormatXMLElementFn = (...args: any[]) => string | object;
273
-
274
- // Singleton
275
- let domParser: DOMParser;
276
- const TOKEN_DELIMITER = '@@';
277
- const TOKEN_REGEX = /@@(\d+_\d+)@@/g;
278
- let counter = 0;
279
- function generateId() {
280
- return `${Date.now()}_${++counter}`;
281
- }
282
-
283
- function restoreRichPlaceholderMessage(
284
- text: string,
285
- objectParts: Record<string, any>
286
- ): Array<string | object> {
287
- return text
288
- .split(TOKEN_REGEX)
289
- .filter(Boolean)
290
- .map(c => (objectParts[c] != null ? objectParts[c] : c))
291
- .reduce((all, c) => {
292
- if (!all.length) {
293
- all.push(c);
294
- } else if (
295
- typeof c === 'string' &&
296
- typeof all[all.length - 1] === 'string'
297
- ) {
298
- all[all.length - 1] += c;
299
- } else {
300
- all.push(c);
301
- }
302
- return all;
303
- }, []);
304
- }
305
-
306
- /**
307
- * Not exhaustive, just for sanity check
308
- */
309
- const SIMPLE_XML_REGEX = /(<([0-9a-zA-Z-_]*?)>(.*?)<\/([0-9a-zA-Z-_]*?)>)|(<[0-9a-zA-Z-_]*?\/>)/;
310
-
311
- const TEMPLATE_ID = Date.now() + '@@';
312
-
313
- const VOID_ELEMENTS = [
314
- 'area',
315
- 'base',
316
- 'br',
317
- 'col',
318
- 'embed',
319
- 'hr',
320
- 'img',
321
- 'input',
322
- 'link',
323
- 'meta',
324
- 'param',
325
- 'source',
326
- 'track',
327
- 'wbr',
328
- ];
329
-
330
- function formatHTMLElement(
331
- el: Element,
332
- objectParts: Record<string, any>,
333
- values: Record<string, PrimitiveType | object | FormatXMLElementFn>
334
- ): Array<PrimitiveType | object> {
335
- let {tagName} = el;
336
- const {outerHTML, textContent, childNodes} = el;
337
- // Regular text
338
- if (!tagName) {
339
- return restoreRichPlaceholderMessage(textContent || '', objectParts);
340
- }
341
-
342
- tagName = tagName.toLowerCase();
343
- const isVoidElement = ~VOID_ELEMENTS.indexOf(tagName);
344
- const formatFnOrValue = values[tagName];
345
-
346
- if (formatFnOrValue && isVoidElement) {
347
- throw new FormatError(
348
- `${tagName} is a self-closing tag and can not be used, please use another tag name.`
349
- );
350
- }
351
-
352
- if (!childNodes.length) {
353
- return [outerHTML];
354
- }
355
-
356
- const chunks: any[] = (Array.prototype.slice.call(
357
- childNodes
358
- ) as ChildNode[]).reduce(
359
- (all: any[], child) =>
360
- all.concat(formatHTMLElement(child as HTMLElement, objectParts, values)),
361
- []
362
- );
363
-
364
- // Legacy HTML
365
- if (!formatFnOrValue) {
366
- return [`<${tagName}>`, ...chunks, `</${tagName}>`];
367
- }
368
- // HTML Tag replacement
369
- if (typeof formatFnOrValue === 'function') {
370
- return [formatFnOrValue(...chunks)];
371
- }
372
- return [formatFnOrValue];
373
- }
374
-
375
- export function formatHTMLMessage(
376
- els: MessageFormatElement[],
377
- locales: string | string[],
378
- formatters: Formatters,
379
- formats: Formats,
380
- values?: Record<string, PrimitiveType | object | FormatXMLElementFn>,
381
- // For debugging
382
- originalMessage?: string
383
- ): Array<string | object> {
384
- const parts = formatToParts(
385
- els,
386
- locales,
387
- formatters,
388
- formats,
389
- values,
390
- undefined,
391
- originalMessage
392
- );
393
- const objectParts: Record<string, ArgumentPart['value']> = {};
394
- const formattedMessage = parts.reduce((all, part) => {
395
- if (part.type === PART_TYPE.literal) {
396
- return (all += part.value);
397
- }
398
- const id = generateId();
399
- objectParts[id] = part.value;
400
- return (all += `${TOKEN_DELIMITER}${id}${TOKEN_DELIMITER}`);
401
- }, '');
402
-
403
- // Not designed to filter out aggressively
404
- if (!SIMPLE_XML_REGEX.test(formattedMessage)) {
405
- return restoreRichPlaceholderMessage(formattedMessage, objectParts);
406
- }
407
- if (!values) {
408
- throw new FormatError('Message has placeholders but no values was given');
409
- }
410
- if (typeof DOMParser === 'undefined') {
411
- throw new FormatError('Cannot format XML message without DOMParser');
412
- }
413
- if (!domParser) {
414
- domParser = new DOMParser();
415
- }
416
-
417
- const content = domParser
418
- .parseFromString(
419
- `<formatted-message id="${TEMPLATE_ID}">${formattedMessage}</formatted-message>`,
420
- 'text/html'
421
- )
422
- .getElementById(TEMPLATE_ID);
423
-
424
- if (!content) {
425
- throw new FormatError(`Malformed HTML message ${formattedMessage}`);
426
- }
427
- const tagsToFormat = Object.keys(values).filter(
428
- varName => !!content.getElementsByTagName(varName).length
429
- );
430
-
431
- // No tags to format
432
- if (!tagsToFormat.length) {
433
- return restoreRichPlaceholderMessage(formattedMessage, objectParts);
434
- }
435
-
436
- const caseSensitiveTags = tagsToFormat.filter(
437
- tagName => tagName !== tagName.toLowerCase()
438
- );
439
- if (caseSensitiveTags.length) {
440
- throw new FormatError(
441
- `HTML tag must be lowercased but the following tags are not: ${caseSensitiveTags.join(
442
- ', '
443
- )}`
444
- );
445
- }
446
-
447
- // We're doing this since top node is `<formatted-message/>` which does not have a formatter
448
- return Array.prototype.slice
449
- .call(content.childNodes)
450
- .reduce(
451
- (all, child) => all.concat(formatHTMLElement(child, objectParts, values)),
452
- []
453
- );
454
- }
276
+ export type FormatXMLElementFn<T> = (
277
+ ...args: Array<string | T>
278
+ ) => string | Array<string | T>;
package/src/index.ts CHANGED
@@ -7,4 +7,5 @@ See the accompanying LICENSE file for terms.
7
7
  import IntlMessageFormat from './core';
8
8
  export * from './formatters';
9
9
  export * from './core';
10
+ export * from './error';
10
11
  export default IntlMessageFormat;