Skip to content

Commit 2670f4c

Browse files
feat(mf2): Add bidirectional isolation for formatted messages
1 parent 70a20dd commit 2670f4c

23 files changed

+284
-74
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/data-model/format-markup.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ export function formatMarkup(
1414
if (options?.size) {
1515
part.options = {};
1616
for (const [name, value] of options) {
17-
if (name === 'u:locale') {
17+
if (name === 'u:dir' || name === 'u:locale') {
1818
const msg = `The option ${name} is not valid for markup`;
1919
const optSource = getValueSource(value);
2020
ctx.onError(new MessageResolutionError('bad-option', msg, optSource));

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

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

56
export class MessageFunctionContext {
67
#ctx: Context;
7-
#locales: string[];
8+
#locales: Intl.Locale[];
9+
readonly dir: 'ltr' | 'rtl' | 'auto' | undefined;
810
readonly source: string;
9-
constructor(ctx: Context, source: string, options: Options | undefined) {
11+
constructor(ctx: Context, source: string, options?: Options) {
1012
this.#ctx = ctx;
11-
const lc = options?.get('u:locale');
12-
if (lc) {
13-
let rl = resolveValue(ctx, lc);
14-
if (typeof rl === 'object' && typeof rl?.valueOf === 'function') {
15-
rl = rl.valueOf();
13+
14+
this.#locales = ctx.locales;
15+
const localeOpt = options?.get('u:locale');
16+
if (localeOpt) {
17+
let rl = resolveValue(ctx, localeOpt);
18+
try {
19+
if (typeof rl === 'object' && typeof rl?.valueOf === 'function') {
20+
rl = rl.valueOf();
21+
}
22+
this.#locales = Array.isArray(rl)
23+
? rl.map(lc => new Intl.Locale(lc))
24+
: [new Intl.Locale(String(rl))];
25+
} catch {
26+
const msg = 'Unsupported value for u:locale option';
27+
const optSource = getValueSource(localeOpt);
28+
ctx.onError(new MessageResolutionError('bad-option', msg, optSource));
29+
}
30+
}
31+
32+
this.dir = undefined;
33+
const dirOpt = options?.get('u:dir');
34+
if (dirOpt) {
35+
const dir = String(resolveValue(ctx, dirOpt));
36+
if (dir === 'ltr' || dir === 'rtl' || dir === 'auto') {
37+
this.dir = dir;
38+
} else {
39+
const msg = 'Unsupported value for u:dir option';
40+
const optSource = getValueSource(dirOpt);
41+
ctx.onError(new MessageResolutionError('bad-option', msg, optSource));
1642
}
17-
this.#locales = Array.isArray(rl) ? rl.map(String) : [String(rl)];
18-
} else {
19-
this.#locales = ctx.locales;
2043
}
44+
2145
this.source = source;
2246
}
47+
2348
get localeMatcher() {
2449
return this.#ctx.localeMatcher;
2550
}
51+
2652
get locales() {
27-
return this.#locales.slice();
53+
return this.#locales.map(String);
2854
}
55+
2956
get onError() {
3057
return this.#ctx.onError;
3158
}

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

Lines changed: 14 additions & 7 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,9 +18,11 @@ 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

@@ -98,9 +101,11 @@ describe('Function return is not a MessageValue', () => {
98101
} satisfies MessageFunctions;
99102
const mf = new MessageFormat('en', '{:fail}', { functions });
100103
const onError = jest.fn();
101-
expect(mf.format(undefined, onError)).toEqual('{:fail}');
104+
expect(mf.format(undefined, onError)).toEqual('\u2068{:fail}\u2069');
102105
expect(mf.formatToParts(undefined, onError)).toEqual([
103-
{ type: 'fallback', source: ':fail' }
106+
{ type: 'bidiIsolation', value: '\u2068' },
107+
{ type: 'fallback', source: ':fail' },
108+
{ type: 'bidiIsolation', value: '\u2069' }
104109
]);
105110
expect(onError).toHaveBeenCalledTimes(2);
106111
});
@@ -111,9 +116,11 @@ describe('Function return is not a MessageValue', () => {
111116
} satisfies MessageFunctions;
112117
const mf = new MessageFormat('en', '{42 :fail}', { functions });
113118
const onError = jest.fn();
114-
expect(mf.format(undefined, onError)).toEqual('{|42|}');
119+
expect(mf.format(undefined, onError)).toEqual('\u2068{|42|}\u2069');
115120
expect(mf.formatToParts(undefined, onError)).toEqual([
116-
{ type: 'fallback', source: '|42|' }
121+
{ type: 'bidiIsolation', value: '\u2068' },
122+
{ type: 'fallback', source: '|42|' },
123+
{ type: 'bidiIsolation', value: '\u2069' }
117124
]);
118125
expect(onError).toHaveBeenCalledTimes(2);
119126
});

mf2/messageformat/src/data-model/literal.test.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,19 @@ function resolve(source: string, errors: any[] = []) {
1515
describe('quoted literals', () => {
1616
test('simple', () => {
1717
const res = resolve('{|quoted literal|}');
18-
expect(res).toMatchObject([{ type: 'string', value: 'quoted literal' }]);
18+
expect(res).toMatchObject([
19+
{ type: 'bidiIsolation', value: '\u2068' },
20+
{ type: 'string', value: 'quoted literal' },
21+
{ type: 'bidiIsolation', value: '\u2069' }
22+
]);
1923
});
2024

2125
test('spaces, newlines and escapes', () => {
2226
const res = resolve('{| quoted \n \\\\\\|literal\\\\\\|\\{\\}|}');
2327
expect(res).toMatchObject([
24-
{ type: 'string', value: ' quoted \n \\|literal\\|{}' }
28+
{ type: 'bidiIsolation', value: '\u2068' },
29+
{ type: 'string', value: ' quoted \n \\|literal\\|{}' },
30+
{ type: 'bidiIsolation', value: '\u2069' }
2531
]);
2632
});
2733
});
@@ -39,7 +45,11 @@ describe('unquoted numbers', () => {
3945
]) {
4046
test(value, () => {
4147
const res = resolve(`{${value}}`);
42-
expect(res).toMatchObject([{ type: 'string', value }]);
48+
expect(res).toMatchObject([
49+
{ type: 'bidiIsolation', value: '\u2068' },
50+
{ type: 'string', value },
51+
{ type: 'bidiIsolation', value: '\u2069' }
52+
]);
4353
});
4454
}
4555
});

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

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,9 @@ describe('Simple open/close', () => {
2626
name: 'b'
2727
},
2828
{ type: 'literal', value: 'foo' },
29-
{ type: 'string', locale: 'en', source: '$foo', value: 'foo bar' },
29+
{ type: 'bidiIsolation', value: '\u2068' },
30+
{ type: 'string', source: '$foo', locale: 'en', value: 'foo bar' },
31+
{ type: 'bidiIsolation', value: '\u2069' },
3032
{
3133
type: 'markup',
3234
kind: 'close',
@@ -35,7 +37,7 @@ describe('Simple open/close', () => {
3537
options: { foo: ' bar 13 ' }
3638
}
3739
]);
38-
expect(mf.format({ foo: 'foo bar' })).toBe('foofoo bar');
40+
expect(mf.format({ foo: 'foo bar' })).toBe('foo\u2068foo bar\u2069');
3941
});
4042

4143
test('do not allow operands', () => {

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ function resolveOptions(ctx: Context, options: Options | undefined) {
5151
const opt: Record<string, unknown> = Object.create(null);
5252
if (options) {
5353
for (const [name, value] of options) {
54-
if (name !== 'u:locale') {
54+
if (name !== 'u:dir' && name !== 'u:locale') {
5555
opt[name] = resolveValue(ctx, value);
5656
}
5757
}

0 commit comments

Comments
 (0)