@etyma/core
v1.0.0
Published
Framework-agnostic internationalization engine for Etyma: JSON catalogs, MessageFormat 2 formatting, locale routing and lazy catalog loading.
Maintainers
Readme
@etyma/core
The portable engine behind Etyma: message catalogs, MessageFormat 2 formatting, locale routing and lazy catalog loading.
Framework agnostic by construction. It imports no Angular, no Analog, no DOM API, no browser
global and no Node built-in — only Intl and
messageformat, the MessageFormat 2 reference
implementation. That constraint is enforced by lint rules and checked against the packed
tarball on every CI run, because it is what lets the same catalog logic run in a browser, in
a Node server, in a Cloudflare Worker and in whatever adapter comes next.
Semver-stable public API. The documented exports, generated formats, diagnostic codes and CLI exit semantics follow semantic versioning; see what the stability promise covers.
Install
pnpm add @etyma/coreMost applications also want @etyma/angular.
The shape of it
import { defineI18n, defineMessages } from '@etyma/core';
import en from './en.json';
export const i18n = defineI18n({
locales: ['en', 'es', 'uk'],
sourceLocale: 'en',
source: en,
loaders: {
es: () => import('./es.json'),
uk: () => import('./uk.json'),
},
});defineI18n validates everything it can at definition time — an unknown source locale, a
malformed language tag, a locale with no loader, a catalog key containing a dot — because
those are configuration bugs and the useful moment to fail is while the file is on screen.
The source catalog's type is the key contract:
import type { MessageKey } from '@etyma/core';
type Key = MessageKey<typeof en>; // 'nav.docs' | 'footer.rights' | …That needs no generation step. A .json import types every message as plain string, so
t() params stay optional and untyped. If you want them typed per message, pass an optional
contract generated from the same file (etyma contract ./en.json --output
./etyma.generated.ts, from @etyma/cli):
import contract from './etyma.generated';
export const i18n = defineI18n({ /* … */ source: en, contract });source stays the runtime catalog; the contract carries only its keys, each message's
variable names and the MessageFormat 2 functions their values reach, types t() param names
and values from them, and makes array keys exact. defineI18n throws if the contract's keys
are not exactly the source's; it does not re-check variables or functions, so regenerate the
contract whenever the source changes, and run etyma contract … --check in CI to catch one
that was not. A defineMessages() source needs no
contract: its params are read from the literal text.
Catalog grammar
A catalog is an object. Each value is one of:
- a string — a MessageFormat 2 message;
- an object — a namespace, whose keys are joined with
.; - a non-empty array of strings — one message per element, keyed by zero-based index.
{ "onboarding": { "steps": ["Create an account", "Choose {$plan}", "Invite your team"] } }flattens to onboarding.steps.0, onboarding.steps.1 and onboarding.steps.2, exactly as
numbered object keys would. Nothing else is a message: numbers, booleans, null, an empty
array, an array element that is not a string (an object, a nested array, a sparse-array
hole) and a key containing . are rejected where they occur - onboarding.steps.1, not
onboarding.steps. walkMessageSource is the one place that grammar is defined;
flattenMessages, defineI18n, every loader and @etyma/tooling all go through it.
MessageKey is exact for an array defineMessages() infers as a tuple
('steps.0' | 'steps.1'). A .json import types an array as string[], with no known
length, so its keys are `steps.${number}`: every index type-checks, and one the catalog
does not have is a missing message at runtime.
API
Two tiers. The first is what an application imports. The second is public on purpose and covered by the same compatibility promise, but it exists for whoever builds on Etyma — a framework adapter, a validator, a code generator — and most applications never import it.
Application API
Definitions
defineI18n(options)— validates and freezes anI18nDefinitionfrom a static source catalog. Takes an optionalcontractfor typed params from a JSON source.defineRemoteI18n(options)— the same for a source catalog that is itself loaded at runtime;contractis required, since it is the only place keys can come from.defineMessages(messages)— authors a catalog in TypeScript instead of JSON.defineMessageContract({ keys, variables?, functions? })— a catalog's keys, optionally each message's variables ({ welcome: ['name'] }) and the functions those variables' values reach ({ total: { count: ['number'] } }), without its text. Usually generated byetyma contractor@etyma/tooling'setymaRemoteContract. Withoutfunctions, every listed param is aMessageParamValue. A field this version does not know is ignored at runtime and is a compile error, so a contract from a newer generator fails by naming the field. (Before 0.5 it took three positional arguments; regenerate such a contract.)createHttpMessageLoader(url, init?)— aMessageLoaderthat fetches one JSON catalog per locale with the platformfetch.
Types
MessageKey<T>— the dotted keys of a catalog shape, as a string literal union.MessageParamsOf<T>— the params each message of a literal catalog shape requires, keyed by dotted key;MessageVariables<S>reads one message's external variables andMessageArgs<TParams, K>is whatt()takes after the key. Messages typed as plainstring(JSON imports) get no entry, and keep optional, untyped params unless a contract lists them.I18nMessageKey<D>,I18nKeysRequiringParams<D>,I18nKeysWithoutRequiredParams<D>andI18nMessageArgs<D, K>— key and argument vocabulary derived from a definition (typeof i18n); see Dynamic keys.NumericMessageParam(number | bigint | string) andDateTimeMessageParam(Date | number | string) — what a param annotated with a built-in numeric (:number,:integer,:offset,:currency,:percent,:unit) or date/time (:date,:time,:datetime) function accepts, from a literal source or a contract. A bare variable,:string, a custom function, an option value, or annotations that disagree stayMessageParamValue. Deliberately semantic:messageformatwould coerce aDateinto:numberas epoch milliseconds, and Etyma rejects it. Types only - a string that is not a number still type-checks for:number, and is reported throughonIssue.EtymaError— the one error type Etyma throws, for configuration mistakes only.
Dynamic keys
t() stays strict: a key typed as a union needs the params of every member. When keys live in
metadata, narrow the key domain instead of loosening t():
import type { I18nKeysWithoutRequiredParams } from '@etyma/core';
type NavigationKey = I18nKeysWithoutRequiredParams<typeof i18n>;
interface NavigationItem {
readonly href: string;
readonly labelKey: NavigationKey;
}
const NAVIGATION = [
{ href: '/', labelKey: 'nav.home' },
] as const satisfies readonly NavigationItem[];
t(NAVIGATION[0].labelKey); // no cast
// labelKey: 'footer.rights' // compile error: it requires paramsI18nMessageKey<D>— every known key, i.e.(typeof i18n.keys)[number].I18nKeysRequiringParams<D>— keys the definition has an exact params entry for. Read from the definition's params map, never from message text.I18nKeysWithoutRequiredParams<D>— the complement. These keys are not forbidden params; they are simply not known to need any, which is the domain safe to call without an argument.I18nMessageArgs<D, K>— whatt()takes afterK, a definition-firstMessageArgs. With it,function tr<K extends I18nMessageKey<typeof i18n>>(key: K, ...args: I18nMessageArgs<typeof i18n, K>)forwards tot(key, ...args)without an assertion.
A JSON source with no contract, a keys-only contract, and any key a partial contract lists no
variables for are all "without known required params". A broad I18nDefinition<string,
MessageParamsMap> knows no key requires params, so the requiring set is never. They work the
same for defineI18n and defineRemoteI18n definitions, and with Angular's injectT and
Astro's etyma.t.
Advanced: adapter and tooling API
What @etyma/angular, @etyma/astro and @etyma/tooling are built from, and what an
adapter for another framework would be built from too.
I18nDefinition— the validated definition every adapter reads:locales,sourceLocale,keys,router,formatting,loaderFor(),directionOf().createCatalogRegistry(definition, options)— per-instance catalog store with load deduplication, source-locale fallback loading, remote contract-drift reporting andCatalogSnapshottransfer for server rendering. One per request on a server.createMessageFormatter(options)— MessageFormat 2, with one parsed message cached per locale and pattern. Never throws: a malformed pattern renders as its own source and is reported throughonIssue.createTranslator(input)— immutable translation bound to one locale and one set of catalogs, with fallback to the source locale.Translator<TKey, TParams>requires params for a keyTParamsrecords variables for.loadMessageCatalog(locale, loader)— runs a loader and normalizes what it returns, including unwrapping the module namespace a dynamic JSON import resolves to.flattenMessages(source)— nested catalog to a flatMapof dotted keys, throwing on the first shape problem;walkMessageSource(source, onLeaf, onProblem)is the walk behind it, reporting every leaf and every shape problem instead.createLocaleRouter(options)—localize,stripandlocaleOffor locale-prefixed paths. The source locale is served unprefixed.localeDirection(locale),isWellFormedLocale(value),assertWellFormedLocale(value, context).
Licence
MIT.
