Skip to content

Commit 4685000

Browse files
Merge pull request #432 from messageformat/mf2-updates
MF2 spec updates: BiDi isolation, u:options
2 parents cbfa8a9 + e6499be commit 4685000

29 files changed

+363
-95
lines changed

mf2/fluent/src/fluent.test.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -331,7 +331,7 @@ for (const [title, { locale = 'en', src, tests }] of Object.entries(
331331
)) {
332332
describe(title, () => {
333333
const data = fluentToResourceData(src).data;
334-
const res = fluentToResource(data, locale);
334+
const res = fluentToResource(data, locale, { bidiIsolation: 'none' });
335335

336336
test('validate', () => {
337337
for (const [id, group] of res) {
@@ -444,6 +444,7 @@ describe('formatToParts', () => {
444444
{
445445
type: 'number',
446446
source: '$num',
447+
dir: 'ltr',
447448
locale: 'en',
448449
parts: [{ type: 'integer', value: '42' }]
449450
}
@@ -455,7 +456,9 @@ describe('formatToParts', () => {
455456
const foo = res.get('foo')?.get('')?.formatToParts(undefined, onError);
456457
expect(foo).toEqual([
457458
{ type: 'literal', value: 'Foo ' },
458-
{ type: 'fallback', source: '$num' }
459+
{ type: 'bidiIsolation', value: '\u2068' },
460+
{ type: 'fallback', source: '$num' },
461+
{ type: 'bidiIsolation', value: '\u2069' }
459462
]);
460463
expect(onError).toHaveBeenCalledTimes(1);
461464
});
@@ -469,7 +472,9 @@ describe('formatToParts', () => {
469472
source: '|foo|',
470473
parts: [
471474
{ type: 'literal', value: 'Foo ' },
472-
{ type: 'fallback', source: '$num' }
475+
{ type: 'bidiIsolation', value: '\u2068' },
476+
{ type: 'fallback', source: '$num' },
477+
{ type: 'bidiIsolation', value: '\u2069' }
473478
]
474479
}
475480
]);
@@ -572,6 +577,7 @@ describe('formatToParts', () => {
572577
{
573578
type: 'number',
574579
source: '$num',
580+
dir: 'ltr',
575581
locale: 'en',
576582
parts: [{ type: 'integer', value: '42' }]
577583
}

mf2/fluent/src/functions.ts

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
1+
import type { MessagePart } from 'messageformat';
12
import type {
23
MessageFunctionContext,
34
MessageValue
45
} from 'messageformat/functions';
6+
import { getLocaleDir } from 'messageformat/functions/utils';
57
import type { FluentMessageResource } from './index.js';
68
import { valueToMessageRef } from './message-to-fluent.js';
79

@@ -20,27 +22,41 @@ import { valueToMessageRef } from './message-to-fluent.js';
2022
*/
2123
export function getFluentFunctions(res: FluentMessageResource) {
2224
function message(
23-
{ locales, onError, source }: MessageFunctionContext,
25+
ctx: MessageFunctionContext,
2426
options: Record<string, unknown>,
2527
input?: unknown
2628
) {
29+
const { onError, source } = ctx;
30+
const locale = ctx.locales[0];
31+
const dir = ctx.dir ?? getLocaleDir(locale);
2732
const { msgId, msgAttr } = valueToMessageRef(input ? String(input) : '');
2833
const mf = res.get(msgId)?.get(msgAttr ?? '');
2934
if (!mf) throw new Error(`Message not available: ${msgId}`);
3035

3136
let str: string | undefined;
3237
return {
3338
type: 'fluent-message' as const,
34-
locale: locales[0],
3539
source,
40+
dir,
41+
locale,
3642
selectKey(keys) {
3743
str ??= mf.format(options, onError);
3844
return keys.has(str) ? str : null;
3945
},
40-
toParts() {
46+
toParts(): [
47+
{
48+
type: 'fluent-message';
49+
source: string;
50+
dir?: 'ltr' | 'rtl';
51+
parts: MessagePart[];
52+
}
53+
] {
4154
const parts = mf.formatToParts(options, onError);
42-
const res = { type: 'fluent-message' as const, source, parts };
43-
return [res] as [typeof res];
55+
const res =
56+
dir === 'ltr' || dir === 'rtl'
57+
? { type: 'fluent-message' as const, source, dir, locale, parts }
58+
: { type: 'fluent-message' as const, source, locale, parts };
59+
return [res];
4460
},
4561
toString: () => (str ??= mf.format(options, onError)),
4662
valueOf: () => (str ??= mf.format(options, onError))

mf2/icu-messageformat-1/src/functions.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
type MessageValue,
66
datetime
77
} from 'messageformat/functions';
8+
import { getLocaleDir } from 'messageformat/functions/utils';
89

910
function getParam(options: Record<string, unknown>) {
1011
if (options.param) {
@@ -107,10 +108,11 @@ function duration(
107108
}
108109

109110
function number(
110-
{ localeMatcher, locales, source }: MessageFunctionContext,
111+
ctx: MessageFunctionContext,
111112
options: Record<string, unknown>,
112113
input?: unknown
113114
): MessageNumber {
115+
const { locales, source } = ctx;
114116
const origNum = typeof input === 'bigint' ? input : Number(input);
115117
let num = origNum;
116118
const offset = Number(options.pluralOffset);
@@ -120,7 +122,7 @@ function number(
120122
}
121123

122124
const opt: Intl.NumberFormatOptions & Intl.PluralRulesOptions = {
123-
localeMatcher
125+
localeMatcher: ctx.localeMatcher
124126
};
125127
switch (getParam(options)) {
126128
case 'integer':
@@ -138,12 +140,17 @@ function number(
138140
if (options.type === 'ordinal') opt.type = 'ordinal';
139141

140142
let locale: string | undefined;
143+
let dir = ctx.dir;
141144
let nf: Intl.NumberFormat | undefined;
142145
let cat: Intl.LDMLPluralRule | undefined;
143146
let str: string | undefined;
144147
return {
145148
type: 'number',
146149
source,
150+
get dir() {
151+
dir ??= getLocaleDir(this.locale);
152+
return dir;
153+
},
147154
get locale() {
148155
return (locale ??= Intl.NumberFormat.supportedLocalesOf(locales, opt)[0]);
149156
},
@@ -161,7 +168,10 @@ function number(
161168
nf ??= new Intl.NumberFormat(locales, opt);
162169
const parts = nf.formatToParts(num);
163170
locale ??= nf.resolvedOptions().locale;
164-
return [{ type: 'number', source, locale, parts }];
171+
dir ??= getLocaleDir(locale);
172+
return dir === 'ltr' || dir === 'rtl'
173+
? [{ type: 'number', source, dir, locale, parts }]
174+
: [{ type: 'number', source, locale, parts }];
165175
},
166176
toString() {
167177
nf ??= new Intl.NumberFormat(locales, opt);

mf2/icu-messageformat-1/src/mf1.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -376,7 +376,7 @@ for (const [title, cases] of Object.entries(testCases)) {
376376

377377
test(strParam.join(', '), () => {
378378
const data = mf1ToMessageData(parse(src));
379-
const mf = mf1ToMessage(data, locale);
379+
const mf = mf1ToMessage(data, locale, { bidiIsolation: 'none' });
380380
const req = validate(data, type => {
381381
throw new Error(`Validation failed: ${type}`);
382382
});

mf2/messageformat/src/cst/names.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,9 +54,9 @@ export function parseNameValue(
5454
if (!isNameStartCode(cc)) return null;
5555
cc = src.charCodeAt(++pos);
5656
while (isNameCharCode(cc)) cc = src.charCodeAt(++pos);
57-
const name = src.substring(nameStart, pos);
57+
const value = src.substring(nameStart, pos).normalize();
5858
if (bidiChars.has(cc)) pos += 1;
59-
return { value: name, end: pos };
59+
return { value, end: pos };
6060
}
6161

6262
export function isValidUnquotedLiteral(str: string): boolean {

mf2/messageformat/src/cst/parse-cst.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -142,10 +142,13 @@ function parseVariant(ctx: ParseContext, start: number): CST.Variant {
142142

143143
if (pos > start && !ws.hasWS) ctx.onError('missing-syntax', pos, "' '");
144144

145-
const key =
146-
ch === '*'
147-
? ({ type: '*', start: pos, end: pos + 1 } satisfies CST.CatchallKey)
148-
: parseLiteral(ctx, pos, true);
145+
let key: CST.CatchallKey | CST.Literal;
146+
if (ch === '*') {
147+
key = { type: '*', start: pos, end: pos + 1 };
148+
} else {
149+
key = parseLiteral(ctx, pos, true);
150+
key.value = key.value.normalize();
151+
}
149152
if (key.end === pos) break; // error; reported in pattern.errors
150153
keys.push(key);
151154
pos = key.end;

mf2/messageformat/src/data-model/format-markup.ts

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1+
import { MessageResolutionError } from '../errors.js';
12
import type { Context } from '../format-context.js';
23
import type { MessageMarkupPart } from '../formatted-parts.js';
3-
import { resolveValue } from './resolve-value.js';
4+
import { getValueSource, resolveValue } from './resolve-value.js';
45
import type { Markup } from './types.js';
56

67
export function formatMarkup(
@@ -13,12 +14,18 @@ export function formatMarkup(
1314
if (options?.size) {
1415
part.options = {};
1516
for (const [name, value] of options) {
16-
let rv = resolveValue(ctx, value);
17-
if (typeof rv === 'object' && typeof rv?.valueOf === 'function') {
18-
const vv = rv.valueOf();
19-
if (vv !== rv) rv = vv;
17+
if (name === 'u:dir' || name === 'u:locale') {
18+
const msg = `The option ${name} is not valid for markup`;
19+
const optSource = getValueSource(value);
20+
ctx.onError(new MessageResolutionError('bad-option', msg, optSource));
21+
} else {
22+
let rv = resolveValue(ctx, value);
23+
if (typeof rv === 'object' && typeof rv?.valueOf === 'function') {
24+
rv = rv.valueOf();
25+
}
26+
if (name === 'u:id') part.id = String(rv);
27+
else part.options[name] = rv;
2028
}
21-
part.options[name] = rv;
2229
}
2330
}
2431
return part;

mf2/messageformat/src/data-model/function-context.ts

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,62 @@
1+
import { MessageResolutionError } from '../errors.js';
12
import type { Context } from '../format-context.js';
3+
import { getValueSource, resolveValue } from './resolve-value.js';
4+
import { Options } from './types.js';
25

36
export class MessageFunctionContext {
47
#ctx: Context;
8+
#locales: Intl.Locale[];
9+
readonly dir: 'ltr' | 'rtl' | 'auto' | undefined;
10+
readonly id: string | undefined;
511
readonly source: string;
6-
constructor(ctx: Context, source: string) {
12+
constructor(ctx: Context, source: string, options?: Options) {
713
this.#ctx = ctx;
14+
15+
this.#locales = ctx.locales;
16+
const localeOpt = options?.get('u:locale');
17+
if (localeOpt) {
18+
let rl = resolveValue(ctx, localeOpt);
19+
try {
20+
if (typeof rl === 'object' && typeof rl?.valueOf === 'function') {
21+
rl = rl.valueOf();
22+
}
23+
this.#locales = Array.isArray(rl)
24+
? rl.map(lc => new Intl.Locale(lc))
25+
: [new Intl.Locale(String(rl))];
26+
} catch {
27+
const msg = 'Unsupported value for u:locale option';
28+
const optSource = getValueSource(localeOpt);
29+
ctx.onError(new MessageResolutionError('bad-option', msg, optSource));
30+
}
31+
}
32+
33+
this.dir = undefined;
34+
const dirOpt = options?.get('u:dir');
35+
if (dirOpt) {
36+
const dir = String(resolveValue(ctx, dirOpt));
37+
if (dir === 'ltr' || dir === 'rtl' || dir === 'auto') {
38+
this.dir = dir;
39+
} else {
40+
const msg = 'Unsupported value for u:dir option';
41+
const optSource = getValueSource(dirOpt);
42+
ctx.onError(new MessageResolutionError('bad-option', msg, optSource));
43+
}
44+
}
45+
46+
const idOpt = options?.get('u:id');
47+
this.id = idOpt ? String(resolveValue(ctx, idOpt)) : undefined;
48+
849
this.source = source;
950
}
51+
1052
get localeMatcher() {
1153
return this.#ctx.localeMatcher;
1254
}
55+
1356
get locales() {
14-
return this.#ctx.locales.slice();
57+
return this.#locales.map(String);
1558
}
59+
1660
get onError() {
1761
return this.#ctx.onError;
1862
}

mf2/messageformat/src/data-model/function-ref.test.ts

Lines changed: 30 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,10 @@ import {
66

77
test('Custom function', () => {
88
const functions = {
9-
custom: ({ source, locales: [locale] }, _opt, input) => ({
9+
custom: ({ dir, source, locales: [locale] }, _opt, input) => ({
1010
type: 'custom',
1111
source,
12+
dir: dir ?? 'auto',
1213
locale,
1314
toParts: () => [
1415
{ type: 'custom', source, locale, value: `part:${input}` }
@@ -17,17 +18,19 @@ test('Custom function', () => {
1718
})
1819
} satisfies MessageFunctions;
1920
const mf = new MessageFormat('en', '{$var :custom}', { functions });
20-
expect(mf.format({ var: 42 })).toEqual('str:42');
21+
expect(mf.format({ var: 42 })).toEqual('\u2068str:42\u2069');
2122
expect(mf.formatToParts({ var: 42 })).toEqual([
22-
{ type: 'custom', source: '$var', locale: 'en', value: 'part:42' }
23+
{ type: 'bidiIsolation', value: '\u2068' },
24+
{ type: 'custom', source: '$var', locale: 'en', value: 'part:42' },
25+
{ type: 'bidiIsolation', value: '\u2069' }
2326
]);
2427
});
2528

2629
describe('inputs with options', () => {
2730
test('local variable with :number expression', () => {
2831
const mf = new MessageFormat(
2932
'en',
30-
`.local $val = {12345678 :number useGrouping=false}
33+
`.local $val = {12345678 :number useGrouping=never}
3134
{{{$val :number minimumFractionDigits=2}}}`
3235
);
3336
//const val = new MessageNumber(null, BigInt(12345678), { options: { useGrouping: false } });
@@ -74,20 +77,28 @@ describe('inputs with options', () => {
7477
});
7578

7679
describe('Type casts based on runtime', () => {
80+
const date = '2000-01-01T15:00:00';
81+
7782
test('boolean function option with literal value', () => {
78-
const mfTrue = new MessageFormat('en', '{$var :number useGrouping=true}');
79-
expect(mfTrue.format({ var: 1234 })).toBe('1,234');
80-
const mfFalse = new MessageFormat('en', '{$var :number useGrouping=false}');
81-
expect(mfFalse.format({ var: 1234 })).toBe('1234');
83+
const mfTrue = new MessageFormat(
84+
'en',
85+
'{$date :datetime timeStyle=short hour12=true}'
86+
);
87+
expect(mfTrue.format({ date })).toMatch(/3:00/);
88+
const mfFalse = new MessageFormat(
89+
'en',
90+
'{$date :datetime timeStyle=short hour12=false}'
91+
);
92+
expect(mfFalse.format({ date })).toMatch(/15:00/);
8293
});
8394

8495
test('boolean function option with variable value', () => {
8596
const mf = new MessageFormat(
8697
'en',
87-
'{$var :number useGrouping=$useGrouping}'
98+
'{$date :datetime timeStyle=short hour12=$hour12}'
8899
);
89-
expect(mf.format({ var: 1234, useGrouping: 'false' })).toBe('1234');
90-
expect(mf.format({ var: 1234, useGrouping: false })).toBe('1234');
100+
expect(mf.format({ date, hour12: 'false' })).toMatch(/15:00/);
101+
expect(mf.format({ date, hour12: false })).toMatch(/15:00/);
91102
});
92103
});
93104

@@ -98,9 +109,11 @@ describe('Function return is not a MessageValue', () => {
98109
} satisfies MessageFunctions;
99110
const mf = new MessageFormat('en', '{:fail}', { functions });
100111
const onError = jest.fn();
101-
expect(mf.format(undefined, onError)).toEqual('{:fail}');
112+
expect(mf.format(undefined, onError)).toEqual('\u2068{:fail}\u2069');
102113
expect(mf.formatToParts(undefined, onError)).toEqual([
103-
{ type: 'fallback', source: ':fail' }
114+
{ type: 'bidiIsolation', value: '\u2068' },
115+
{ type: 'fallback', source: ':fail' },
116+
{ type: 'bidiIsolation', value: '\u2069' }
104117
]);
105118
expect(onError).toHaveBeenCalledTimes(2);
106119
});
@@ -111,9 +124,11 @@ describe('Function return is not a MessageValue', () => {
111124
} satisfies MessageFunctions;
112125
const mf = new MessageFormat('en', '{42 :fail}', { functions });
113126
const onError = jest.fn();
114-
expect(mf.format(undefined, onError)).toEqual('{|42|}');
127+
expect(mf.format(undefined, onError)).toEqual('\u2068{|42|}\u2069');
115128
expect(mf.formatToParts(undefined, onError)).toEqual([
116-
{ type: 'fallback', source: '|42|' }
129+
{ type: 'bidiIsolation', value: '\u2068' },
130+
{ type: 'fallback', source: '|42|' },
131+
{ type: 'bidiIsolation', value: '\u2069' }
117132
]);
118133
expect(onError).toHaveBeenCalledTimes(2);
119134
});

0 commit comments

Comments
 (0)