@hi18n/core
Version:
Message internationalization meets immutability and type-safety - core runtime
250 lines (243 loc) • 7.59 kB
text/typescript
import { defaultErrorHandler, type ErrorHandler } from "./error-handling.ts";
import {
ArgumentTypeError,
MessageEvaluationError,
MissingArgumentError,
} from "./errors.ts";
import { parseMessage } from "./msgfmt-parser.ts";
import type { CompiledMessage } from "./msgfmt.ts";
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 "string":
if (typeof value !== "string")
throw new MessageEvaluationError(
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions, @typescript-eslint/no-base-to-string
`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 offsetValue =
typeof value === "bigint"
? value - BigInt(msg.subtract)
: value - msg.subtract;
// TODO: Remove this fallback because it is nowadays widely supported.
if (typeof Intl === "undefined" || !Intl.NumberFormat) {
const fallback = numberFormatFallback(offsetValue, msg.argStyle);
if (fallback != null) {
(options.handleError ?? defaultErrorHandler)(
new Error("Missing Intl.NumberFormat"),
"warn",
);
return fallback;
}
}
// TODO: allow injecting polyfill
return new Intl.NumberFormat(options.locale, msg.argStyle).format(
offsetValue,
);
}
case "datetime": {
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,
...msg.argStyle,
};
// TODO: allow injecting polyfill
return new Intl.DateTimeFormat(options.locale, formatOptions).format(
value,
);
}
default:
throw new Error(
`Unimplemented: argType=${((msg as { argType: never }).argType as string) ?? "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.subtract;
} else if (typeof value === "bigint") {
relativeValue = value - BigInt(msg.subtract);
} else {
throw new ArgumentTypeError({
argName: msg.name,
expectedType: "number",
got: value,
});
}
const rule: string = (() => {
// TODO: Remove this fallback because it is nowadays widely supported.
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) {
return evaluateMessage(branch.message, options, relativeValue);
}
}
return evaluateMessage(msg.fallback, options, relativeValue);
} 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,
);
} else if (msg.type === "DeferredParseError") {
// Try to reproduce the parse error to produce a more useful stack trace.
parseMessage(msg.sourceText);
// Fallback: re-throw the original error.
throw msg.error;
}
throw new MessageEvaluationError("Invalid message");
}
function numberFormatFallback(
value: number | bigint,
options: Intl.NumberFormatOptions,
): string | undefined {
const {
style = "decimal",
minimumIntegerDigits,
minimumFractionDigits,
maximumFractionDigits,
minimumSignificantDigits,
maximumSignificantDigits,
roundingPriority,
roundingIncrement,
roundingMode,
trailingZeroDisplay,
notation,
compactDisplay,
useGrouping,
signDisplay,
} = options;
if (
style !== "decimal" ||
minimumIntegerDigits != null ||
minimumFractionDigits != null ||
(maximumFractionDigits != null && maximumFractionDigits !== 0) ||
minimumSignificantDigits != null ||
maximumSignificantDigits != null ||
roundingPriority != null ||
roundingIncrement != null ||
roundingMode != null ||
trailingZeroDisplay != null ||
notation != null ||
compactDisplay != null ||
useGrouping != null ||
signDisplay != null
) {
return undefined;
}
if (typeof value === "bigint") {
return `${value}`;
} else if (maximumFractionDigits === 0) {
return `${Math.round(value)}`;
} else {
return `${value}`;
}
}
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"
);
}