Skip to content

Commit 4bb0fdc

Browse files
feat(mf2): Add :currency function (unicode-org/message-format-wg#915)
1 parent 8489042 commit 4bb0fdc

File tree

5 files changed

+232
-1
lines changed

5 files changed

+232
-1
lines changed

mf2/messageformat/src/errors.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,8 @@ export class MessageResolutionError extends MessageError {
8888
| 'bad-function-result'
8989
| 'bad-operand'
9090
| 'bad-option'
91-
| 'unresolved-variable';
91+
| 'unresolved-variable'
92+
| 'unsupported-operation';
9293
source: string;
9394
constructor(
9495
type: typeof MessageResolutionError.prototype.type,
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import { MessageFormat } from '../index.js';
2+
3+
describe('fractionDigits', () => {
4+
for (const fd of [0, 2, 'auto' as const]) {
5+
test(`fractionDigits=${fd}`, () => {
6+
const mf = new MessageFormat(
7+
'en',
8+
`{42 :currency currency=EUR fractionDigits=${fd}}`
9+
);
10+
const nf = new Intl.NumberFormat('en', {
11+
style: 'currency',
12+
currency: 'EUR',
13+
minimumFractionDigits: fd === 'auto' ? undefined : fd,
14+
maximumFractionDigits: fd === 'auto' ? undefined : fd
15+
});
16+
expect(mf.format()).toEqual(nf.format(42));
17+
expect(mf.formatToParts()).toMatchObject([
18+
{ parts: nf.formatToParts(42) }
19+
]);
20+
});
21+
}
22+
});
23+
24+
describe('currencyDisplay', () => {
25+
for (const cd of [
26+
'narrowSymbol',
27+
'symbol',
28+
'name',
29+
'code',
30+
'formalSymbol',
31+
'never'
32+
]) {
33+
test(`currencyDisplay=${cd}`, () => {
34+
const mf = new MessageFormat(
35+
'en',
36+
`{42 :currency currency=EUR currencyDisplay=${cd}}`
37+
);
38+
const nf = new Intl.NumberFormat('en', {
39+
style: 'currency',
40+
currency: 'EUR',
41+
currencyDisplay:
42+
cd === 'formalSymbol' || cd === 'never' ? undefined : cd
43+
});
44+
const onError = jest.fn();
45+
expect(mf.format(undefined, onError)).toEqual(nf.format(42));
46+
expect(mf.formatToParts(undefined, onError)).toMatchObject([
47+
{ parts: nf.formatToParts(42) }
48+
]);
49+
if (cd === 'formalSymbol' || cd === 'never') {
50+
expect(onError.mock.calls).toMatchObject([
51+
[{ type: 'unsupported-operation' }],
52+
[{ type: 'unsupported-operation' }]
53+
]);
54+
} else {
55+
expect(onError.mock.calls).toMatchObject([]);
56+
}
57+
});
58+
}
59+
});
60+
61+
test('select=ordinal', () => {
62+
const mf = new MessageFormat(
63+
'en',
64+
'.local $n = {42 :currency currency=EUR select=ordinal} .match $n * {{res}}'
65+
);
66+
const onError = jest.fn();
67+
expect(mf.format(undefined, onError)).toEqual('res');
68+
expect(onError.mock.calls).toMatchObject([[{ type: 'bad-option' }]]);
69+
});
70+
71+
describe('complex operand', () => {
72+
test(':currency result', () => {
73+
const mf = new MessageFormat(
74+
'en',
75+
'.local $n = {-42 :currency currency=USD trailingZeroDisplay=stripIfInteger} {{{$n :currency currencySign=accounting}}}'
76+
);
77+
const nf = new Intl.NumberFormat('en', {
78+
style: 'currency',
79+
currencySign: 'accounting',
80+
// @ts-expect-error TS doesn't know about trailingZeroDisplay
81+
trailingZeroDisplay: 'stripIfInteger',
82+
currency: 'USD'
83+
});
84+
expect(mf.format()).toEqual(nf.format(-42));
85+
expect(mf.formatToParts()).toMatchObject([
86+
{ parts: nf.formatToParts(-42) }
87+
]);
88+
});
89+
90+
test('external variable', () => {
91+
const mf = new MessageFormat('en', '{$n :currency}');
92+
const nf = new Intl.NumberFormat('en', {
93+
style: 'currency',
94+
currency: 'EUR'
95+
});
96+
const n = { valueOf: () => 42, options: { currency: 'EUR' } };
97+
expect(mf.format({ n })).toEqual(nf.format(42));
98+
expect(mf.formatToParts({ n })).toMatchObject([
99+
{ parts: nf.formatToParts(42) }
100+
]);
101+
});
102+
});
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
import { MessageError, MessageResolutionError } from '../errors.js';
2+
import type { MessageFunctionContext } from './index.js';
3+
import { type MessageNumber, number } from './number.js';
4+
import { asPositiveInteger, asString } from './utils.js';
5+
6+
/**
7+
* `currency` accepts as input numerical values as well as
8+
* objects wrapping a numerical value that also include a `currency` property.
9+
*
10+
* @beta
11+
*/
12+
export function currency(
13+
ctx: MessageFunctionContext,
14+
exprOpt: Record<string | symbol, unknown>,
15+
input?: unknown
16+
): MessageNumber {
17+
const { source } = ctx;
18+
const options: Intl.NumberFormatOptions &
19+
Intl.PluralRulesOptions & { select?: 'exact' | 'cardinal' } = {
20+
localeMatcher: ctx.localeMatcher
21+
};
22+
let value = input;
23+
if (typeof value === 'object') {
24+
const valueOf = value?.valueOf;
25+
if (typeof valueOf === 'function') {
26+
Object.assign(options, (value as { options: unknown }).options);
27+
value = valueOf.call(value);
28+
}
29+
}
30+
if (typeof value === 'string') {
31+
try {
32+
value = JSON.parse(value);
33+
} catch {
34+
// handled below
35+
}
36+
}
37+
if (typeof value !== 'bigint' && typeof value !== 'number') {
38+
const msg = 'Input is not numeric';
39+
throw new MessageResolutionError('bad-operand', msg, source);
40+
}
41+
42+
options.style = 'currency';
43+
for (const [name, optval] of Object.entries(exprOpt)) {
44+
if (optval === undefined) continue;
45+
try {
46+
switch (name) {
47+
case 'compactDisplay':
48+
case 'currency':
49+
case 'currencySign':
50+
case 'notation':
51+
case 'numberingSystem':
52+
case 'roundingMode':
53+
case 'roundingPriority':
54+
case 'trailingZeroDisplay':
55+
// @ts-expect-error Let Intl.NumberFormat construction fail
56+
options[name] = asString(optval);
57+
break;
58+
case 'minimumIntegerDigits':
59+
case 'minimumSignificantDigits':
60+
case 'maximumSignificantDigits':
61+
case 'roundingIncrement':
62+
// @ts-expect-error TS types don't know about roundingIncrement
63+
options[name] = asPositiveInteger(optval);
64+
break;
65+
case 'currencyDisplay': {
66+
const strval = asString(optval);
67+
if (strval === 'formalSymbol' || strval === 'never') {
68+
throw new MessageResolutionError(
69+
'unsupported-operation',
70+
`Currency display "${strval}" is not supported on :currency`,
71+
source
72+
);
73+
}
74+
options[name] = strval;
75+
break;
76+
}
77+
case 'fractionDigits': {
78+
const strval = asString(optval);
79+
if (strval === 'auto') {
80+
options.minimumFractionDigits = undefined;
81+
options.maximumFractionDigits = undefined;
82+
} else {
83+
const numval = asPositiveInteger(strval);
84+
options.minimumFractionDigits = numval;
85+
options.maximumFractionDigits = numval;
86+
}
87+
break;
88+
}
89+
case 'select': {
90+
const strval = asString(optval);
91+
if (strval === 'ordinal') {
92+
throw new MessageResolutionError(
93+
'bad-option',
94+
'Ordinal selection is not supported on :currency',
95+
source
96+
);
97+
}
98+
// @ts-expect-error Let Intl.NumberFormat construction fail
99+
options[name] = strval;
100+
break;
101+
}
102+
case 'useGrouping': {
103+
const strval = asString(optval);
104+
// @ts-expect-error TS type is wrong
105+
options[name] = strval === 'never' ? false : strval;
106+
break;
107+
}
108+
}
109+
} catch (error) {
110+
if (error instanceof MessageError) {
111+
ctx.onError(error);
112+
} else {
113+
const msg = `Value ${optval} is not valid for :currency option ${name}`;
114+
ctx.onError(new MessageResolutionError('bad-option', msg, source));
115+
}
116+
}
117+
}
118+
119+
if (!options.currency) {
120+
const msg = 'A currency code is required for :currency';
121+
throw new MessageResolutionError('bad-operand', msg, source);
122+
}
123+
124+
return number(ctx, {}, { valueOf: () => value, options });
125+
}

mf2/messageformat/src/functions/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { MessageExpressionPart } from '../formatted-parts.js';
22

33
export type { MessageFunctionContext } from '../resolve/function-context.js';
4+
export { currency } from './currency.js';
45
export { type MessageDateTime, date, datetime, time } from './datetime.js';
56
export { type MessageFallback, fallback } from './fallback.js';
67
export { math } from './math.js';

mf2/messageformat/src/messageformat.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import type { Context } from './format-context.js';
77
import type { MessagePart } from './formatted-parts.js';
88
import {
99
MessageValue,
10+
currency,
1011
date,
1112
datetime,
1213
integer,
@@ -22,6 +23,7 @@ import { UnresolvedExpression } from './resolve/resolve-variable.js';
2223
import { selectPattern } from './select-pattern.js';
2324

2425
const defaultFunctions = Object.freeze({
26+
currency,
2527
date,
2628
datetime,
2729
integer,

0 commit comments

Comments
 (0)