@hi18n/core
Version:
Message internationalization meets immutability and type-safety - core runtime
222 lines (217 loc) • 7.09 kB
text/typescript
import { defaultErrorHandler, ErrorHandler } from "./error-handling.js";
import {
ArgumentTypeError,
MessageEvaluationError,
MissingArgumentError,
} from "./errors.js";
import { CompiledMessage } from "./msgfmt.js";
export type EvalOption<T> = {
id?: string | undefined;
locale: string;
timeZone?: string | undefined;
params?: Record<string, unknown>;
handleError?: ErrorHandler | undefined;
collect?: ((submessages: (T | string)[]) => T | string) | undefined;
wrap?:
| ((component: unknown, message: T | string | undefined) => T | string)
| undefined;
};
export function evaluateMessage<T = string>(
msg: CompiledMessage,
options: EvalOption<T>,
numberValue?: number | bigint
): T | string {
if (typeof msg === "string") {
return msg;
} else if (Array.isArray(msg)) {
const reduced = reduceSubmessages(
msg.map((part) => evaluateMessage(part, options, numberValue))
);
if (typeof reduced === "string") {
return reduced;
}
const { collect } = options;
if (!collect)
throw new MessageEvaluationError(
"Invalid message: not a default-collectable message"
);
return collect(reduced);
} else if (msg.type === "Var") {
const value = (options.params ?? {})[msg.name];
if (value === undefined)
throw new MissingArgumentError({
argName: msg.name,
});
switch (msg.argType) {
case undefined:
if (typeof value !== "string")
throw new MessageEvaluationError(
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
`Invalid argument ${msg.name}: expected string, got ${value}`
);
return value;
case "number": {
if (typeof value !== "number" && typeof value !== "bigint") {
throw new ArgumentTypeError({
argName: msg.name,
expectedType: "number",
got: value,
});
}
const formatOptions: Intl.NumberFormatOptions = {};
let modifiedValue = value;
switch (msg.argStyle) {
case "integer":
// https://github.com/openjdk/jdk/blob/739769c8fc4b496f08a92225a12d07414537b6c0/src/java.base/share/classes/sun/util/locale/provider/NumberFormatProviderImpl.java#L196-L198
formatOptions.maximumFractionDigits = 0;
if (typeof value === "number") modifiedValue = Math.round(value);
break;
case "currency":
// Need to provide an appropriate currency from somewhere
throw new Error("Unimplemented: argStyle=currency");
case "percent":
formatOptions.style = msg.argStyle;
break;
}
if (
(typeof Intl === "undefined" || !Intl.NumberFormat) &&
(msg.argStyle === undefined || msg.argStyle === "integer")
) {
(options.handleError ?? defaultErrorHandler)(
new Error("Missing Intl.NumberFormat"),
"warn"
);
return `${modifiedValue}`;
}
// TODO: allow injecting polyfill
return new Intl.NumberFormat(options.locale, formatOptions).format(
modifiedValue
);
}
case "date":
case "time": {
if (!isDateLike(value)) {
throw new ArgumentTypeError({
argName: msg.name,
expectedType: "Date",
got: value,
});
}
if (typeof options.timeZone !== "string") {
throw new MissingArgumentError({
argName: "timeZone",
});
}
const formatOptions: Intl.DateTimeFormatOptions = {
timeZone: options.timeZone,
};
if (typeof msg.argStyle === "object") {
// parsed object from the skeleton
Object.assign(formatOptions, msg.argStyle);
} else {
if (msg.argType === "date") {
formatOptions.dateStyle = msg.argStyle ?? "medium";
} else {
formatOptions.timeStyle = msg.argStyle ?? "medium";
}
}
// TODO: allow injecting polyfill
return new Intl.DateTimeFormat(options.locale, formatOptions).format(
value
);
}
default:
throw new Error(`Unimplemented: argType=${msg.argType ?? "string"}`);
}
} else if (msg.type === "Plural") {
const value = (options.params ?? {})[msg.name];
let relativeValue: number | bigint;
if (value === undefined) {
throw new MissingArgumentError({
argName: msg.name,
});
}
if (typeof value === "number") {
relativeValue = value - (msg.offset ?? 0);
} else if (typeof value === "bigint") {
relativeValue = value - BigInt(msg.offset ?? 0);
} else {
throw new ArgumentTypeError({
argName: msg.name,
expectedType: "number",
got: value,
});
}
const rule: string = (() => {
if (typeof Intl === "undefined" || !Intl.PluralRules) {
(options.handleError ?? defaultErrorHandler)(
new Error("Missing Intl.PluralRules"),
"warn"
);
return "other";
}
// TODO: allow injecting polyfill
const pluralRules = new Intl.PluralRules(options.locale);
return pluralRules.select(Number(relativeValue));
})();
for (const branch of msg.branches) {
if (
branch.selector === Number(value) ||
branch.selector === rule ||
branch.selector === "other"
) {
return evaluateMessage(branch.message, options, relativeValue);
}
}
throw new MessageEvaluationError(
`Non-exhaustive plural branches for ${value}`
);
} else if (msg.type === "Number" && numberValue !== undefined) {
// TODO: allow injecting polyfill
return new Intl.NumberFormat(options.locale).format(numberValue);
} else if (msg.type === "Element") {
const { wrap } = options;
if (!wrap)
throw new MessageEvaluationError(
"Invalid message: unexpected elementArg"
);
const value = (options.params ?? {})[msg.name];
if (value === undefined)
throw new MissingArgumentError({
argName: msg.name,
});
return wrap(
value,
msg.message !== undefined
? evaluateMessage(msg.message, options, numberValue)
: undefined
);
}
throw new MessageEvaluationError("Invalid message");
}
function reduceSubmessages<T>(
submessages: (T | string)[]
): string | (T | string)[] {
if (submessages.every((x): x is string => typeof x === "string")) {
return submessages.join("");
}
const reduced: (T | string)[] = [];
for (const x of submessages) {
if (x === "") continue;
if (
typeof x === "string" &&
typeof reduced[reduced.length - 1] === "string"
) {
reduced[reduced.length - 1] += x;
} else {
reduced.push(x);
}
}
return reduced;
}
function isDateLike(obj: unknown): obj is Date {
return (
typeof obj === "object" &&
typeof (obj as { getFullYear?: unknown }).getFullYear === "function"
);
}