messageformat
Version:
Intl.MessageFormat / Unicode MessageFormat 2 parser, runtime and polyfill
224 lines (223 loc) • 8.99 kB
JavaScript
import { parseMessage } from "./data-model/parse.js";
import { validate } from "./data-model/validate.js";
import { FSI, LRI, PDI, RLI, getLocaleDir } from "./dir-utils.js";
import { MessageFunctionError } from "./errors.js";
import { DefaultFunctions } from "./functions/index.js";
import { BIDI_ISOLATE } from "./message-value.js";
import { formatMarkup } from "./resolve/format-markup.js";
import { resolveExpression } from "./resolve/resolve-expression.js";
import { UnresolvedExpression } from "./resolve/resolve-variable.js";
import { selectPattern } from "./select-pattern.js";
/**
* A message formatter for that implements the
* {@link https://www.unicode.org/reports/tr35/tr35-76/tr35-messageFormat.html#contents-of-part-9-messageformat | LDML 48 MessageFormat}
* specification as well as the {@link https://github.com/tc39/proposal-intl-messageformat/ | TC39 Intl.MessageFormat proposal}.
*
* @category Formatting
* @typeParam T - The `type` used by custom message functions, if any.
* These extend the {@link DefaultFunctions | default functions}.
* @typeParam P - The formatted-parts `type` used by any custom message values.
*/
export class MessageFormat {
#bidiIsolation;
#dir;
#localeMatcher;
#locales;
#message;
#functions;
constructor(locales, source, options) {
this.#bidiIsolation = options?.bidiIsolation !== 'none';
this.#localeMatcher = options?.localeMatcher ?? 'best fit';
this.#locales = Array.isArray(locales)
? locales.map(lc => new Intl.Locale(lc))
: locales
? [new Intl.Locale(locales)]
: [];
this.#dir = options?.dir ?? getLocaleDir(this.#locales[0]);
this.#message = typeof source === 'string' ? parseMessage(source) : source;
validate(this.#message);
this.#functions = options?.functions
? Object.assign(Object.create(null), DefaultFunctions, options.functions)
: DefaultFunctions;
}
/**
* Format a message to a string.
*
* ```js
* import { MessageFormat } from 'messageformat';
* import { DraftFunctions } from 'messageformat/functions';
*
* const msg = 'Hello {$user.name}, today is {$date :date style=long}';
* const mf = new MessageFormat('en', msg, { functions: DraftFunctions });
* mf.format({ user: { name: 'Kat' }, date: new Date('2025-03-01') });
* ```
*
* ```js
* 'Hello Kat, today is March 1, 2025'
* ```
*
* @param msgParams - Values that may be referenced by `$`-prefixed variable references.
* To refer to an inner property of an object value,
* use `.` as a separator; in case of conflict, the longest starting substring wins.
* @param onError - Called in case of error.
* If not set, errors are by default logged as warnings.
*/
format(msgParams, onError) {
const ctx = this.#createContext(msgParams, onError);
let res = '';
for (const elem of selectPattern(ctx, this.#message)) {
if (typeof elem === 'string') {
res += elem;
}
else if (elem.type === 'markup') {
// Handle errors, but discard results
formatMarkup(ctx, elem);
}
else {
let mv;
try {
mv = resolveExpression(ctx, elem);
if (typeof mv.toString === 'function') {
if (this.#bidiIsolation &&
(this.#dir !== 'ltr' || mv.dir !== 'ltr' || mv[BIDI_ISOLATE])) {
const pre = mv.dir === 'ltr' ? LRI : mv.dir === 'rtl' ? RLI : FSI;
res += pre + mv.toString() + PDI;
}
else {
res += mv.toString();
}
}
else {
const error = new MessageFunctionError('not-formattable', 'Message part is not formattable');
error.source = mv.source;
throw error;
}
}
catch (error) {
ctx.onError(error);
const errStr = `{${mv?.source ?? '�'}}`;
res += this.#bidiIsolation ? FSI + errStr + PDI : errStr;
}
}
}
return res;
}
/**
* Format a message to a sequence of parts.
*
* ```js
* import { MessageFormat } from 'messageformat';
* import { DraftFunctions } from 'messageformat/functions';
*
* const msg = 'Hello {$user.name}, today is {$date :date style=long}';
* const mf = new MessageFormat('en', msg, { functions: DraftFunctions });
* mf.formatToParts({ user: { name: 'Kat' }, date: new Date('2025-03-01') });
* ```
*
* ```js
* [
* { type: 'text', value: 'Hello ' },
* { type: 'bidiIsolation', value: '\u2068' },
* { type: 'string', locale: 'en', value: 'Kat' },
* { type: 'bidiIsolation', value: '\u2069' },
* { type: 'text', value: ', today is ' },
* {
* type: 'datetime',
* dir: 'ltr',
* locale: 'en',
* parts: [
* { type: 'month', value: 'March' },
* { type: 'literal', value: ' ' },
* { type: 'day', value: '1' },
* { type: 'literal', value: ', ' },
* { type: 'year', value: '2025' }
* ]
* }
* ]
* ```
*
* @param msgParams - Values that may be referenced by `$`-prefixed variable references.
* To refer to an inner property of an object value,
* use `.` as a separator; in case of conflict, the longest starting substring wins.
* @param onError - Called in case of error.
* If not set, errors are by default logged as warnings.
*/
formatToParts(msgParams, onError) {
const ctx = this.#createContext(msgParams, onError);
const parts = [];
for (const elem of selectPattern(ctx, this.#message)) {
if (typeof elem === 'string') {
parts.push({ type: 'text', value: elem });
}
else if (elem.type === 'markup') {
parts.push(formatMarkup(ctx, elem));
}
else {
let mv;
try {
mv = resolveExpression(ctx, elem);
if (typeof mv.toParts === 'function') {
// Let's presume that parts that look like MessageNumberPart or MessageStringPart are such.
const mp = mv.toParts();
if (this.#bidiIsolation &&
(this.#dir !== 'ltr' || mv.dir !== 'ltr' || mv[BIDI_ISOLATE])) {
const pre = mv.dir === 'ltr' ? LRI : mv.dir === 'rtl' ? RLI : FSI;
parts.push({ type: 'bidiIsolation', value: pre }, ...mp, {
type: 'bidiIsolation',
value: PDI
});
}
else {
parts.push(...mp);
}
}
else {
const error = new MessageFunctionError('not-formattable', 'Message part is not formattable');
error.source = mv.source;
throw error;
}
}
catch (error) {
ctx.onError(error);
const fb = {
type: 'fallback',
source: mv?.source ?? '�'
};
if (this.#bidiIsolation) {
parts.push({ type: 'bidiIsolation', value: FSI }, fb, {
type: 'bidiIsolation',
value: PDI
});
}
else {
parts.push(fb);
}
}
}
}
return parts;
}
#createContext(msgParams, onError = (error) => {
// Emit warning for errors by default
try {
process.emitWarning(error);
}
catch {
console.warn(error);
}
}) {
const scope = { ...msgParams };
for (const decl of this.#message.declarations) {
scope[decl.name] = new UnresolvedExpression(decl.value, decl.type === 'input' ? (msgParams ?? {}) : undefined);
}
const ctx = {
onError,
localeMatcher: this.#localeMatcher,
locales: this.#locales,
localVars: new WeakSet(),
functions: this.#functions,
scope
};
return ctx;
}
}