npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@etyma/core

v1.0.0

Published

Framework-agnostic internationalization engine for Etyma: JSON catalogs, MessageFormat 2 formatting, locale routing and lazy catalog loading.

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/core

Most 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 an I18nDefinition from a static source catalog. Takes an optional contract for typed params from a JSON source.
  • defineRemoteI18n(options) — the same for a source catalog that is itself loaded at runtime; contract is 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 by etyma contract or @etyma/tooling's etymaRemoteContract. Without functions, every listed param is a MessageParamValue. 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?) — a MessageLoader that fetches one JSON catalog per locale with the platform fetch.

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 and MessageArgs<TParams, K> is what t() takes after the key. Messages typed as plain string (JSON imports) get no entry, and keep optional, untyped params unless a contract lists them.
  • I18nMessageKey<D>, I18nKeysRequiringParams<D>, I18nKeysWithoutRequiredParams<D> and I18nMessageArgs<D, K> — key and argument vocabulary derived from a definition (typeof i18n); see Dynamic keys.
  • NumericMessageParam (number | bigint | string) and DateTimeMessageParam (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 stay MessageParamValue. Deliberately semantic: messageformat would coerce a Date into :number as epoch milliseconds, and Etyma rejects it. Types only - a string that is not a number still type-checks for :number, and is reported through onIssue.
  • 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 params
  • I18nMessageKey<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> — what t() takes after K, a definition-first MessageArgs. With it, function tr<K extends I18nMessageKey<typeof i18n>>(key: K, ...args: I18nMessageArgs<typeof i18n, K>) forwards to t(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 and CatalogSnapshot transfer 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 through onIssue.
  • 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 key TParams records 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 flat Map of 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, strip and localeOf for locale-prefixed paths. The source locale is served unprefixed.
  • localeDirection(locale), isWellFormedLocale(value), assertWellFormedLocale(value, context).

Licence

MIT.