@hi18n/core
Version:
Message internationalization meets immutability and type-safety - core runtime
760 lines (709 loc) • 20.5 kB
text/typescript
/* eslint-disable @typescript-eslint/ban-types, @typescript-eslint/no-explicit-any */
import { CompiledMessage } from "./msgfmt.js";
import { EvalOption, evaluateMessage } from "./msgfmt-eval.js";
import { parseMessage } from "./msgfmt-parser.js";
import type {
ComponentPlaceholder,
InferredMessageType,
} from "./msgfmt-parser-types.js";
import {
MessageError,
MissingLocaleError,
MissingTranslationError,
NoLocaleError,
} from "./errors.js";
import {
defaultErrorHandler,
ErrorHandler,
ErrorLevel,
} from "./error-handling.js";
export type { ComponentPlaceholder } from "./msgfmt-parser-types.js";
export * from "./errors.js";
export * from "./error-handling.js";
declare const messageBrandSymbol: unique symbol;
declare const translationIdBrandSymbol: unique symbol;
/**
* A subtype of `string` that represents translation messages.
*
* @param Args parameters required by this message
*
* @since 0.1.0 (`@hi18n/core`)
*/
export type Message<Args = {}> = string & {
[messageBrandSymbol]: (args: Args) => void;
};
/**
* A base type for a vocabulary.
*
* A vocabulary here means a set of translation ids required for this book of translations.
*
* @since 0.1.0 (`@hi18n/core`)
*/
export type VocabularyBase = Record<string, Message<any>>;
/**
* Extracts parameters required by the translated message.
*
* @param M the message being instantiated.
* @param C replacement for the component interpolation (like `<0></0>` or `<link></link>`).
*
* @since 0.1.0 (`@hi18n/core`)
*/
export type MessageArguments<
M extends Message<any>,
C
> = InstantiateComponentTypes<
InjectAdditionalParams<AbstractMessageArguments<M>>,
C
>;
// It if uses Date, we need timeZone as well.
export type InjectAdditionalParams<Args> = true extends HasDate<
Args[keyof Args]
>
? Args & { timeZone: string }
: Args;
export type HasDate<T> = T extends Date ? true : never;
export type AbstractMessageArguments<M extends Message<any>> =
M extends Message<infer Args> ? Args : never;
export type InstantiateComponentTypes<Args, C> = {
[K in keyof Args]: InstantiateComponentType<Args[K], C>;
};
export type InstantiateComponentType<T, C> = T extends ComponentPlaceholder
? C
: T;
/**
* A subtype of `string` that represents a dynamically-managed translation id.
*
* @param Vocabulary the vocabulary type of the Book it refers to
* @param Args parameters required by this message
*
* @since 0.1.1 (`@hi18n/core`)
*/
export type TranslationId<
Vocabulary extends VocabularyBase,
Args = {}
> = string & {
[translationIdBrandSymbol]: (catalog: Vocabulary, args: Args) => void;
};
/**
* Extracts the translation ids that don't take parameters.
*
* @param Vocabulary the vocabulary, a set of translation ids we can use for this book of translations.
* @param K a dummy parameter to do a union distribution
*
* @since 0.1.0 (`@hi18n/core`)
*/
export type SimpleMessageKeys<
Vocabulary extends VocabularyBase,
K extends string & keyof Vocabulary = string & keyof Vocabulary
> = K extends unknown
? {} extends MessageArguments<Vocabulary[K], never>
? K
: never
: never;
/**
* Infers the appropriate type for the translated message.
*
* At runtime, it just returns the first argument.
*
* @param s the translated message
* @returns the first argument
*
* @since 0.1.0 (`@hi18n/core`)
*
* @example
* ```ts
* export default new Book<Vocabulary>({
* "example/greeting": msg("Hello, {name}!"),
* });
* ```
*/
export function msg<S extends string>(s: S): InferredMessageType<S> {
return s as InferredMessageType<S>;
}
/**
* Same as {@link msg} but can be used to indicate an untranslated state.
*
* At runtime, it just returns the first argument.
*
* @param s the translated message
* @returns the first argument
*
* @since 0.1.3 (`@hi18n/core`)
*
* @example
* ```ts
* export default new Book<Vocabulary>({
* "example/greeting": msg.todo("Hello, {name}!"),
* });
* ```
*/
msg.todo = function todo<S extends string>(s: S): InferredMessageType<S> {
return s as InferredMessageType<S>;
};
/**
* Marks a translation id as dynamically used with {@link CompoundTranslatorFunction.dynamic t.dynamic}.
*
* At runtime, it just returns the second argument.
*
* @param book the book the id is linked to. Just discarded at runtime.
* @param id the translation id.
* @returns the second argument
*
* @since 0.1.1 (`@hi18n/core`)
*
* @example
* ```ts
* const menus = [
* {
* url: "https://example.com/home",
* titleId: translationId(book, "example/navigation/home"),
* },
* {
* url: "https://example.com/map",
* titleId: translationId(book, "example/navigation/map"),
* },
* ];
*
* const { t } = getTranslator(book, "en");
* t.dynamic(menus[i].titleId);
* ```
*/
export function translationId<
Vocabulary extends VocabularyBase,
K extends string & keyof Vocabulary
>(
book: Book<Vocabulary>,
id: K
): TranslationId<Vocabulary, AbstractMessageArguments<Vocabulary[K]>> {
const _book = book;
return id as string as TranslationId<
Vocabulary,
AbstractMessageArguments<Vocabulary[K]>
>;
}
/**
* A set of translated messages, containing translations for all supported locales.
*
* In other words, a book is a set of {@link Catalog}s for all languages.
*
* @since 0.1.0 (`@hi18n/core`)
*
* @example
* ```ts
* type Vocabulary = {
* "example/greeting": Message<{ name: string }>;
* };
* export const book = new Book<Vocabulary>({
* en: catalogEn,
* ja: catalogJa,
* });
* ```
*
* @example You can use `import()` to lazy-load catalogs.
* Note that you need to use extra setup to avoid the
* "Catalog not loaded" error.
*
* ```ts
* type Vocabulary = {
* "example/greeting": Message<{ name: string }>;
* };
* export const book = new Book<Vocabulary>({
* en: () => import("./en"),
* ja: () => import("./ja"),
* });
* ```
*/
export class Book<Vocabulary extends VocabularyBase> {
readonly catalogs: Record<string, Catalog<Vocabulary>>;
readonly _loaders: Readonly<
Record<string, Catalog<Vocabulary> | CatalogLoader<Vocabulary>>
>;
_handleError?: ErrorHandler | undefined;
_implicitLocale?: string | undefined;
constructor(
catalogs: Readonly<
Record<string, Catalog<Vocabulary> | CatalogLoader<Vocabulary>>
>,
options: BookOptions = {}
) {
this.catalogs = {};
this._loaders = catalogs;
this._handleError = options.handleError;
this._implicitLocale = options.implicitLocale;
for (const [locale, catalog] of Object.entries(catalogs)) {
// Skip lazy-loaded catalogs
if (typeof catalog === "function") continue;
this.catalogs[locale] = catalog;
// @ts-expect-error deliberately breaking privacy
if (catalog._looseLocale) {
catalog.locale = locale;
} else if (catalog.locale !== locale) {
throw new Error(
`Locale mismatch: expected ${locale}, got ${catalog.locale!}`
);
}
}
if (
this._implicitLocale != null &&
!hasOwn(catalogs, this._implicitLocale)
) {
throw new Error(`Invalid implicitLocale: ${this._implicitLocale}`);
}
}
/**
* Load a catalog for specific locale.
*
* Consider using {@link preloadCatalogs} instead.
*
* @param locale locale to load
*
* @since 0.1.9 (`@hi18n/core`)
*/
public async loadCatalog(locale: string): Promise<void> {
const loader = this._loaders[locale];
if (typeof loader !== "function") return;
const { default: catalog } = await loader();
// @ts-expect-error deliberately breaking privacy
if (catalog._looseLocale) {
catalog.locale = locale;
} else if (catalog.locale !== locale) {
throw new Error(
`Locale mismatch: expected ${locale}, got ${catalog.locale!}`
);
}
if (this.catalogs[locale] != null) return;
this.catalogs[locale] = catalog;
}
public handleError(e: Error, level: ErrorLevel) {
(this._handleError ?? defaultErrorHandler)(e, level);
}
}
/**
* @since 0.1.7 (`@hi18n/core`)
*/
export type BookOptions = {
/**
* Custom error handler. {@link defaultErrorHandler} is used by default.
*
* @example
* ```ts
* export const book = new Book({
* en: catalogEn,
* ja: catalogJa,
* }, {
* handleError(error, level) {
* if (level === "error") {
* // Report to Sentry or somewhere
* } else {
* console.warn(error);
* }
* }
* });
* ```
*
* @since 0.1.7 (`@hi18n/core`)
*/
handleError?: ErrorHandler | undefined;
/**
* Locale fallback to use when no valid locale is specified.
*
* @example
* ```ts
* export const book = new Book({
* en: catalogEn,
* ja: catalogJa,
* }, { implicitLocale: "en" });
* ```
*
* @since 0.1.7 (`@hi18n/core`)
*/
implicitLocale?: string | undefined;
};
/**
* A function to asynchronously load a catalog.
*
* It is usually provided as `() => import("...")`.
*
* @since 0.1.9 (`@hi18n/core`)
*/
export type CatalogLoader<Vocabulary extends VocabularyBase> = () => Promise<{
default: Catalog<Vocabulary>;
}>;
/**
* A set of translated messages for a specific locale.
*
* @since 0.1.0 (`@hi18n/core`)
*
* @example
* ```ts
* type Vocabulary = {
* "example/greeting": Message<{ name: string }>;
* };
* export default new Catalog<Vocabulary>("en", {
* "example/greeting": msg("Hello, {name}!"),
* });
* ```
*/
export class Catalog<Vocabulary extends VocabularyBase> {
// TODO: make it non-nullish and readonly in 0.2.0
public locale?: string | undefined;
public readonly data: Readonly<Vocabulary>;
// TODO: remove it in 0.2.0
private _looseLocale = false;
private _compiled: Record<string, CompiledMessage> = {};
/**
* @since 0.1.6 (`@hi18n/core`)
*/
constructor(locale: string, data: Readonly<Vocabulary>);
/**
* @deprecated deprecated from 0.1.6. Please specify the locale.
* @since 0.1.0 (`@hi18n/core`)
*/
constructor(data: Readonly<Vocabulary>);
constructor(
locale: string | Readonly<Vocabulary>,
data?: Readonly<Vocabulary>
) {
if (typeof locale === "object") {
// For backwards-compatibility
// TODO: remove it in 0.2.0
this.data = locale;
this._looseLocale = true;
} else {
this.locale = locale;
this.data = data!;
}
}
getCompiledMessage(id: string & keyof Vocabulary): CompiledMessage {
if (!hasOwn(this._compiled, id)) {
if (!hasOwn(this.data, id)) {
throw new MissingTranslationError();
}
const msg = this.data[id]!;
this._compiled[id] = parseMessage(msg);
}
return this._compiled[id]!;
}
}
/**
* An object returned from {@link getTranslator}.
*
* @since 0.1.0 (`@hi18n/core`)
*/
export type TranslatorObject<Vocabulary extends VocabularyBase> = {
/**
* Returns the translated message.
*
* @since 0.1.0 (`@hi18n/core`)
*
* @example
* ```ts
* const { t } = getTranslator(book, "en");
* t("example/greeting-simple"); // => "Hello!"
* ```
*/
t: CompoundTranslatorFunction<Vocabulary>;
/**
* Similar to {@link TranslatorObject.t} but allows component interpolation
* (i.e. to interpret commands like `<0>foo</0>` or `<link>foo</link>`)
*
* Users usually don't need to call it manually.
* See the `@hi18n/react` package for its application to React.
*
* @param id the id of the translation
* @param interpolator functions to customize the interpolation behavior
* @param options the parameters of the translation.
*
* @since 0.1.0 (`@hi18n/core`)
*/
translateWithComponents<T, C, K extends string & keyof Vocabulary>(
id: K,
interpolator: ComponentInterpolator<T, C>,
options: MessageArguments<Vocabulary[K], C>
): T | string;
};
type CompoundTranslatorFunction<Vocabulary extends VocabularyBase> =
TranslatorFunction<Vocabulary> & {
/**
* Returns the translated message for a dynamic id.
*
* @since 0.1.1 (`@hi18n/core`)
*
* @example
* ```ts
* const { t } = getTranslator(book, "en");
* t.dynamic(menus[i].titleId); // => "Map"
* ```
*/
dynamic: DynamicTranslatorFunction<Vocabulary>;
/**
* Declares a translation to be made.
*
* At runtime, it returns the first argument.
*
* @param id the id of the translation
* @param options the parameters of the translation.
*
* @since 0.1.1 (`@hi18n/core`)
*
* @example
* ```ts
* const { t } = getTranslator(book, "en");
* t.todo("example/greeting-simple"); // => "[TODO: example/greeting-simple]"
* ```
*/
todo(id: string, options?: Record<string, unknown>): string;
};
type TranslatorFunction<Vocabulary extends VocabularyBase> = {
/**
* Returns the translated message for a simple one.
*
* @param id the id of the translation
*
* @since 0.1.0 (`@hi18n/core`)
*
* @example
* ```ts
* const { t } = getTranslator(book, "en");
* t("example/greeting-simple"); // => "Hello!"
* ```
*/
(id: SimpleMessageKeys<Vocabulary>): string;
/**
* Returns the translated message.
*
* @param id the id of the translation
* @param options the parameters of the translation.
*
* @since 0.1.0 (`@hi18n/core`)
*
* @example
* ```ts
* const { t } = getTranslator(book, "en");
* t("example/greeting", { name: "John" }); // => "Hello, John!"
* ```
*/
<K extends string & keyof Vocabulary>(
id: K,
options: MessageArguments<Vocabulary[K], never>
): string;
};
type DynamicTranslatorFunction<Vocabulary extends VocabularyBase> = {
/**
* Returns the translated message for a simple dynamic id.
*
* @param id the id of the translation
*
* @since 0.1.1 (`@hi18n/core`)
*
* @example
* ```ts
* const { t } = getTranslator(book, "en");
* t.dynamic(menus[i].titleId); // => "Map"
* ```
*/
(id: TranslationId<Vocabulary, {}>): string;
/**
* Returns the translated message for a dynamic id.
*
* @param id the id of the translation
* @param options the parameters of the translation.
*
* @since 0.1.1 (`@hi18n/core`)
*
* @example
* ```ts
* const { t } = getTranslator(book, "en");
* t.dynamic(greetings[i].translationId, { name: "John" }); // => "Hello, John!"
* ```
*/
<Args>(
id: TranslationId<Vocabulary, Args>,
options: InstantiateComponentTypes<InjectAdditionalParams<Args>, never>
): string;
};
/**
* Used in {@link TranslatorObject.translateWithComponents} to customize
* the behavior of component interpolation.
*
* @since 0.1.0 (`@hi18n/core`)
*/
export type ComponentInterpolator<T, C> = {
collect: (submessages: (T | string)[]) => T | string;
wrap: (component: C, message: T | string | undefined) => T | string;
};
/**
* Retrieves the translation helpers from the book and the locales.
*
* @param book the "book" (i.e. the set of translations) containing the desired messages.
* @param locales a locale or a list of locale in the order of preference (the latter being not supported yet)
* @param options.throwPromise if true, it throws a Promise instance instead of an error. Used for React Suspense integration.
* @returns A set of translation helpers
*
* @since 0.1.0 (`@hi18n/core`)
*
* @example
* ```ts
* const { t } = getTranslator(book, "en");
* t("example/greeting-simple"); // => "Hello!"
* ```
*/
export function getTranslator<Vocabulary extends VocabularyBase>(
book: Book<Vocabulary>,
locales: string | string[],
options: GetTranslatorOptions = {}
): TranslatorObject<Vocabulary> {
const locale = selectLocale(book, locales);
const catalog = book.catalogs[locale];
if (catalog == null) {
if (options.throwPromise) {
throw book.loadCatalog(locale);
} else {
throw new Error(`Catalog not loaded: ${locale}`);
}
}
const t = (id: string, options: Record<string, unknown> = {}): string => {
try {
return compileAndEvaluateMessage<Vocabulary, string>(
catalog,
locale,
id,
{
timeZone: options["timeZone"] as string | undefined,
params: options,
handleError: book._handleError,
}
);
} catch (e) {
if (!(e instanceof Error)) throw e;
book.handleError(e, "error");
return `[${id}]`;
}
};
t.dynamic = t;
t.todo = (id: string) => {
return `[TODO: ${id}]`;
};
return {
t: t as CompoundTranslatorFunction<Vocabulary>,
translateWithComponents: <T, C, K extends string & keyof Vocabulary>(
id: K,
interpolator: ComponentInterpolator<T, C>,
options: MessageArguments<Vocabulary[K], C>
): T | string => {
try {
return compileAndEvaluateMessage<Vocabulary, T>(catalog, locale, id, {
timeZone: options["timeZone"] as string | undefined,
params: options,
handleError: book._handleError,
collect: interpolator.collect,
wrap: interpolator.wrap as ComponentInterpolator<T, unknown>["wrap"],
});
} catch (e) {
if (!(e instanceof Error)) throw e;
book.handleError(e, "error");
return `[${id}]`;
}
},
};
}
/**
* options for {@link getTranslator}
*
* @since 0.1.9 (`@hi18n/core`)
*/
export type GetTranslatorOptions = {
/** if true, it throws a Promise instance instead of an error. Used for React Suspense integration. */
throwPromise?: boolean | undefined;
};
/**
* Starts loading and waits for catalogs so that {@link getTranslator} does not error
* with "Catalog not loaded" error.
*
* It is a wrapper for {@link Book.loadCatalog}.
*
* @param book same as {@link getTranslator}'s `book` parameter.
* @param locales same as {@link getTranslator}'s `locales` parameter.
*
* @since 0.1.9 (`@hi18n/core`)
*/
export async function preloadCatalogs<Vocabulary extends VocabularyBase>(
book: Book<Vocabulary>,
locales: string | string[]
): Promise<void> {
const locale = selectLocale(book, locales);
const catalog = book.catalogs[locale];
if (catalog == null) {
await book.loadCatalog(locale);
}
}
// To ensure deterministic behavior,
// this function picks the locale whether the catalog has already been loaded or not.
function selectLocale<Vocabulary extends VocabularyBase>(
book: Book<Vocabulary>,
locales: string | string[]
): string {
const localesArray = Array.isArray(locales) ? locales : [locales];
const filteredLocales: string[] = [];
for (const locale of localesArray) {
if (hasOwn(book._loaders, locale)) {
filteredLocales.push(locale);
}
}
if (filteredLocales.length === 0) {
const error =
localesArray.length === 0
? new NoLocaleError()
: new MissingLocaleError({
locale: localesArray[0]!,
availableLocales: Object.keys(book._loaders),
});
if (book._implicitLocale != null) {
book.handleError(error, "error");
filteredLocales.push(book._implicitLocale);
} else {
throw error;
}
}
return filteredLocales[0]!;
}
function compileAndEvaluateMessage<Vocabulary extends VocabularyBase, T>(
catalog: Catalog<Vocabulary>,
locale: string,
id: string & keyof Vocabulary,
options: Omit<EvalOption<T>, "id" | "locale">
) {
try {
return evaluateMessage<T>(catalog.getCompiledMessage(id), {
id,
locale,
...options,
});
} catch (e) {
if (!(e instanceof Error)) throw e;
throw new MessageError({
cause: e,
id,
locale,
});
}
}
/**
* A convenience helper to get the default time zone.
* If you need more sophisticated guess for old browsers,
* consider using other libraries like `moment.tz.guess`.
*
* @returns the default time zone, if anything is found. Otherwise the string "UTC"
*
* @since 0.1.3 (`@hi18n/core`)
*/
export function getDefaultTimeZone(): string {
if (typeof Intl !== "undefined" && Intl.DateTimeFormat) {
const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
if (typeof timeZone === "string") return timeZone;
}
return "UTC";
}
function hasOwn(o: object, s: PropertyKey): boolean {
return Object.prototype.hasOwnProperty.call(o, s);
}