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

ilingo

v6.0.0

Published

This is a lightweight library for translation.

Readme

ilingo 💬

npm version codecov Master Workflow Known Vulnerabilities semantic-release: angular

Ilingo is a lightweight library for translation and internationalization. The core's only runtime dependencies are pathtrace and smob; on common workloads it runs 1.6× – 2.3× faster than i18next (benchmarks).

Table of Contents

Installation

npm install ilingo --save

Configuration

While full localization of an application is a complex subject, swapping out strings in your application for different supported languages/locales is simple. The different locale strings for translation are provided by interacting with the library class instance.

Usage

Basic

Create an instance and set the default locale.

import { Ilingo } from 'ilingo';

const ilingo = new Ilingo({
    locale: 'en'
})

The default (memory-) store is initialized with a catalog built from the defineCatalog / defineLocale / defineNamespace / defineTranslations tree helpers.

import {
    Ilingo,
    MemoryStore,
    defineCatalog,
    defineLocale,
    defineNamespace,
    defineTranslations,
} from 'ilingo';

const catalog = defineCatalog([
    // locale: de
    defineLocale('de', [
        // namespace: app
        defineNamespace('app', [
            defineTranslations({ key: 'Hallo mein Name ist {{name}}' }),
        ]),
    ]),
    // locale: en
    defineLocale('en', [
        defineNamespace('app', [
            defineTranslations({ key: 'Hello my name is {{name}}' }),
        ]),
    ]),
]);

const store = new MemoryStore({ data: catalog });

const ilingo = new Ilingo({
    store,
    locale: 'en'
});

To retrieve text from any of the language files, simply pass the filename/namespace and the access key as the first parameter, separated by a period (.).

After that you can simply access the locale string, as described in the following:

import { Ilingo } from 'ilingo';

const ilingo = new Ilingo({
    // ...
});

await ilingo.get({
    namespace: 'app',
    key: 'key'
});
// Hello my name is {{name}}

await ilingo.get({
    namespace: 'app',
    key: 'key',
    data: {
        name: 'Peter'
    }
});
// Hello my name is Peter

await ilingo.get({
    namespace: 'app',
    key: 'key',
    data: {
        name: 'Peter'
    },
    locale: 'de'
});
// Hallo mein Name ist Peter

Parameters

As a template delimiter a mustache like {{}} interpolation is used. Data properties can be injected as a second argument, e.g.

import { Ilingo, MemoryStore, defineCatalog, defineLocale, defineNamespace, defineTranslations } from 'ilingo';

const ilingo = new Ilingo({
    store: new MemoryStore({
        data: defineCatalog([
            defineLocale('en', [
                defineNamespace('app', [
                    defineTranslations({ age: 'I am {{age}} years old.' }),
                ]),
            ]),
        ]),
    })
});

await ilingo.get({
    namespace: 'app',
    key: 'age',
    data: {
        age: 18
    }
});
// I am 18 yeas old

Locales

The default locale can be modified after initialization:

import { Ilingo } from 'ilingo';

const ilingo = new Ilingo({
    // ...
});

await ilingo.get({
    namespace: 'app',
    key: 'age',
    data: {
        age: 18
    }
});
// I am 18 yeas old

ilingo.setLocale('de');

await ilingo.get({
    namespace: 'app',
    key: 'age',
    data: {
        age: 18
    }
});
// Ich bin 18 Jahre alt

It can also be temporarily overwritten per call, by passing a locale in the translation context:

import { Ilingo } from 'ilingo';

const ilingo = new Ilingo({
    // ...
});

await ilingo.get({
    namespace: 'app',
    key: 'age',
    data: {
        age: 18
    }
});
// I am 18 yeas old

await ilingo.get({
    namespace: 'app',
    key: 'age',
    data: {
        age: 18
    },
    locale: 'fr'
});
// J'ai 18 ans

await ilingo.get({
    namespace: 'app',
    key: 'age',
    data: {
        age: 18
    },
    locale: 'de'
});
// Ich bin 18 Jahre alt

Lazy

Another option is to add translations on the fly and access them afterwards.

import { Ilingo, MemoryStore, defineCatalog, defineLocale, defineNamespace, defineTranslations } from 'ilingo';

const ilingo = new Ilingo({
    store: new MemoryStore({
        data: defineCatalog([
            defineLocale('en', [
                defineNamespace('foo', [
                    defineTranslations({ bar: 'baz {{param}}' }),
                ]),
            ]),
            defineLocale('de', [
                defineNamespace('foo', [
                    defineTranslations({ bar: 'boz {{param}}' }),
                ]),
            ]),
        ]),
    })
});

await ilingo.get({
    namespace: 'foo',
    key: 'bar',
    data: {
        param: 'x'
    }
});
// baz x

await ilingo.get({
    namespace: 'foo',
    key: 'bar',
    data: {
        param: 'y'
    },
    locale: 'de'
});
// boz y

Pluralization

Plural forms are CLDR categories (zero | one | two | few | many | other, other required); the matching form is selected via Intl.PluralRules. The count is automatically merged into data so {{count}} works without restating it.

A plural leaf is a dedicated plural node — built with the definePlural helper in TS / JS, or written as a { "type": "plural", "data": { ... } } literal in JSON. A plural node is the only thing interpreted as a plural; a plain object inside defineTranslations is always treated as a nested key.

TS / JS files (inline defineCatalog, or loaded by FSStore) — use the definePlural helper:

import {
    Ilingo,
    MemoryStore,
    defineCatalog,
    defineLocale,
    defineNamespace,
    defineTranslations,
    definePlural,
} from 'ilingo';

const catalog = defineCatalog([
    defineLocale('en', [
        defineNamespace('cart', [
            defineTranslations({
                items: definePlural({
                    one: '{{count}} item',
                    other: '{{count}} items',
                }),
            }),
        ]),
    ]),
]);

const ilingo = new Ilingo({
    store: new MemoryStore({ data: catalog }),
});

await ilingo.get({ namespace: 'cart', key: 'items', count: 1 });  // "1 item"
await ilingo.get({ namespace: 'cart', key: 'items', count: 5 });  // "5 items"

JSON files (loaded by FSStore) — use the literal plural node:

{
    "type": "translations",
    "data": {
        "items": {
            "type": "plural",
            "data": {
                "one": "{{count}} item",
                "other": "{{count}} items"
            }
        }
    }
}

definePlural returns a plural node with the same runtime shape as the JSON { "type": "plural", "data": { ... } } literal. The TS/JS version gives local CLDR-category autocomplete and a compile error if you misspell other or pass a non-CLDR key.

If the selected category is absent from the leaf, other is used as a fallback.

Plural leaves round-trip through store.set()StoreSetContext.value accepts either a string or a plural node (definePlural(...) / { type: 'plural', data }), not the unwrapped PluralForms, so the store can recognise and unwrap it on read. The FSStore.set persistence writes them as JSON unchanged.

Fallback locale chain

get() walks an ordered fallback chain. By default the chain is derived from BCP-47 parents of the requested locale, terminating at en:

new Ilingo({ locale: 'pt-BR' }).getResolvedLocaleChain({ locale: 'pt-BR' });
// ['pt-BR', 'pt', 'en']

Override with fallback:

new Ilingo({ fallback: 'es' });            // string
new Ilingo({ fallback: ['es', 'fr'] });    // array, in order
new Ilingo({                               // function: per-call
    fallback: (locale) => locale.startsWith('pt') ? ['es'] : [],
});
new Ilingo({ fallback: false });           // disable fallback entirely
new Ilingo({ fallback: [] });              // equivalent to `false`

The chain is walked locale-first across all stores — the closest locale match wins regardless of store order. Within a single locale, stores are queried serially in insertion order, stopping at the first hit. A network-backed adapter registered after a Memory adapter is never called when the Memory adapter answers — the orchestrator does not pre-fan-out across stores.

Inspect the resolution with:

ilingo.getResolvedLocaleChain({ locale: 'pt-BR' });
// ['pt-BR', 'pt', 'en']

await ilingo.getResolvedLocale({ namespace: 'app', key: 'hi' });
// 'pt'   — which locale actually yielded a value
// undefined if no store had the key anywhere in the chain

Missing-key handler

Override the default dev-mode console.warn via onMissingKey. Return a string to make it the result of get(); return undefined to keep the result undefined.

const ilingo = new Ilingo({
    onMissingKey: ({ namespace, key, resolvedLocale }) => {
        track('i18n.miss', { namespace, key, locale: resolvedLocale });
        return `[missing: ${namespace}.${key}]`;
    },
});

Formatters

Template placeholders support modifiers backed by Intl.NumberFormat, Intl.DateTimeFormat, and Intl.ListFormat:

const ilingo = new Ilingo({
    store: new MemoryStore({
        data: defineCatalog([
            defineLocale('en', [
                defineNamespace('app', [
                    defineTranslations({
                        owe: 'You owe {{amount, number(style=currency, currency=EUR)}}',
                        signed: 'Signed {{date, date(dateStyle=medium, timeZone=UTC)}}',
                        invited: '{{people, list(style=long, type=conjunction)}}',
                    }),
                ]),
            ]),
        ]),
    }),
});

await ilingo.get({ namespace: 'app', key: 'owe',     data: { amount: 99 } });           // "You owe €99.00"
await ilingo.get({ namespace: 'app', key: 'signed',  data: { date: '2026-05-22T12:00:00Z' } }); // "Signed May 22, 2026"
await ilingo.get({ namespace: 'app', key: 'invited', data: { people: ['Alice', 'Bob', 'Carol'] } });
// → "Alice, Bob, and Carol"

Syntax:

{{value}}                          plain substitution
{{value, formatter}}               formatter with no options
{{value, formatter(k=v, k2=v2)}}   formatter with options

The locale used to construct the Intl.*Format instance is the resolved locale — the one that actually yielded the message via the fallback chain — not the requested one. Intl.*Format instances are memoised per (formatter, locale, options) on the Ilingo instance, so repeated renders do not reallocate.

Option-value coercion: 4242 (number), true / false → boolean, anything else → string. So currency=EUR becomes { currency: 'EUR' }, minimumFractionDigits=2 becomes { minimumFractionDigits: 2 }.

Unknown modifiers fall back to String(value) and emit a one-shot dev-mode warning (silenced in process.env.NODE_ENV === 'production'). Malformed modifier expressions (unbalanced parens, non-identifier names) are treated the same way — never throw.

Authoring catalogs

A catalog is a tree of tagged descriptor nodes built with four helpers:

  • defineCatalog(locales) — the root; the value you pass to new MemoryStore({ data }). Its children are defineLocale(...) nodes.
  • defineLocale(name, children) — one locale ('en', 'de', …). Its children are defineNamespace(...) (and/or defineTranslations(...)) nodes.
  • defineNamespace(name, children) — a namespace. Its children are nested defineNamespace(...) and/or defineTranslations(...) nodes.
  • defineTranslations(obj) — a flat or key-nested map of translation strings (and definePlural(...) leaves).
import {
    Ilingo,
    MemoryStore,
    defineCatalog,
    defineLocale,
    defineNamespace,
    defineTranslations,
    definePlural,
} from 'ilingo';

const catalog = defineCatalog([
    defineLocale('en', [
        defineNamespace('app', [
            defineTranslations({ greeting: 'Hi {{name}}', nav: { home: 'Home' } }), // nav.home is a dotted KEY
        ]),
        defineNamespace('cart', [
            defineTranslations({ items: definePlural({ one: '{{count}} item', other: '{{count}} items' }) }),
        ]),
    ]),
]);

const ilingo = new Ilingo({ store: new MemoryStore({ data: catalog }) });

await ilingo.get({ namespace: 'app', key: 'greeting', data: { name: 'Peter' } }); // "Hi Peter"
await ilingo.get({ namespace: 'app', key: 'nav.home' });                          // "Home"
await ilingo.get({ namespace: 'cart', key: 'items', count: 5 });                  // "5 items"

There are two independent nesting hierarchies:

  • Nested defineNamespace builds a dotted namespace. defineNamespace('app', [defineNamespace('nav', …)]) exposes the inner namespace as 'app.nav'.
  • A nested object inside defineTranslations builds a dotted key. defineTranslations({ nav: { home: 'Home' } }) exposes the leaf as key 'nav.home'.

get() keys are loose strings and get() returns Promise<string | undefined>. The store model is open-world — API- and loader-backed stores hold keys that aren't known at build time — so there is no catalog-driven key inference. definePlural still gives you local CLDR-category autocomplete and a compile error on a missing other (or a non-CLDR key), but it does not drive get()'s key types.

defineCatalog and friends are runtime functions that normalize the tree into the internal Locales shape (normalizeCatalog / normalizeNamespaceBody are exported for advanced use). Node types (CatalogNode, LocaleNode, NamespaceNode, TranslationsNode, PluralNode, NamespaceChild, CatalogInput, NamespaceBodyInput) and guards (isCatalogNode, isLocaleNode, isNamespaceNode, isTranslationsNode, isPluralNode) are exported too.

The IIlingo interface

IIlingo is the public type contract of the orchestrator — every method on the concrete Ilingo class plus the stores map and formatters registry. Library code that accepts an orchestrator (@ilingo/vue, @ilingo/vuelidate, @ilingo/validup, …) accepts and returns IIlingo, so consumers can swap in test doubles or decorating wrappers without depending on the concrete class.

import type { IIlingo } from 'ilingo';

function register(ilingo: IIlingo) {
    ilingo.registerStore(myStore); // myStore.id = Symbol.for('@scope/pkg')
}

new Ilingo() is still the way to construct an instance. Prefer IIlingo as the type position; reserve Ilingo (the class) for construction and instanceof checks.

Slot placeholders & tokenize()

In addition to {{var}} data placeholders (and modifier syntax), messages can carry {slot} markers (single curly braces) for renderers that produce structured output rather than a string. The core tokenize(str) helper parses a message into text / var / slot tokens:

import { tokenize } from 'ilingo';

tokenize('Hi {{user}}, please {cta} now.');
// [
//   { kind: 'text', value: 'Hi ' },
//   { kind: 'var', name: 'user' },
//   { kind: 'text', value: ', please ' },
//   { kind: 'slot', name: 'cta' },
//   { kind: 'text', value: ' now.' },
// ]

tokenize() and template() are parallel parsers — template() returns a substituted string (used by Ilingo.format); tokenize() returns tokens for VNode-producing renderers (e.g. @ilingo/vue's <ITranslateT>). Plain Ilingo.get() always returns a string, so {slot} markers survive into the output unless a slot-aware renderer consumes them.

Custom formatters

Register your own modifier names alongside the built-in number / date / list:

const ilingo = new Ilingo({
    store: /* ... */,
    formatters: {
        upper: (value, _opts, locale) => String(value).toLocaleUpperCase(locale),
        relative: (value, _opts, locale) => {
            const rtf = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' });
            return rtf.format(Number(value), 'day');
        },
    },
});

await ilingo.get({
    namespace: 'app', key: 'shout',
    data: { name: 'peter' },
});
// "{{name, upper}}" → "PETER"

Or call ilingo.registerFormatter(name, fn) after construction. Custom formatters receive (value, options, locale)options is the parsed {key=value, ...} from inside the modifier parens.

IlingoOptions.formatters overrides win against the built-ins by name, so you can swap the default number formatter for a custom one if needed.

Locale negotiation

negotiateLocale(supported, requested) picks the best match between a list of supported locales and a list of requested ones (typically from an HTTP Accept-Language header). Implements BCP-47 best-match: exact match → prefix match → parent walk.

import { Ilingo, MemoryStore, negotiateLocale, parseAcceptLanguage } from 'ilingo';

const ilingo = new Ilingo({
    store: new MemoryStore({ data: /* ... */ }),
});

const supported = ['en', 'de', 'pt-BR'];

negotiateLocale(supported, ['pt-PT', 'pt', 'en']);
// → 'pt-BR'  (requested 'pt' matches supported 'pt-BR' as a parent prefix)

const fromHeader = parseAcceptLanguage('en-US,en;q=0.9,de;q=0.8');
// → ['en-US', 'en', 'de']

const chosen = negotiateLocale(supported, fromHeader) ?? 'en';
ilingo.setLocale(chosen);

parseAcceptLanguage(header) parses the RFC 9110 header into a quality-sorted tag list. Both functions are pure utilities — they don't mutate Ilingo state. Compose them with setLocale() to wire request-side locale negotiation in a server (Express / Hono / etc.) or client (navigator.languages).

Store

A store implements the read IStore port — id, get, getLocales. ilingo is read-first: the orchestrator only ever reads (it never calls set), so that's the whole required contract, and it is frozen for the stable release. Writing is an opt-in capability — IMutableStore adds set(ctx) and is implemented by MemoryStore (in-memory) and FSStore (disk); extendStore(...) takes a IMutableStore, and isMutableStore(store) is the runtime guard. Other capabilities (cache invalidation, file watching, …) layer the same way (see Invalidation below). has, delete, getKeys, and batch getAll were each considered and deferred — see the JSDoc on IStore in packages/ilingo/src/store/types.ts for the per-method rationale.

Registering stores — registerStore(store)

Ilingo holds its stores in a public readonly stores: Map<symbol | string, IStore>, keyed by each store's own id identity, queried serially in insertion order (first hit wins). Add stores with registerStore:

const ilingo = new Ilingo({ store: appStore }); // constructor seeds the first store
ilingo.registerStore(overrideStore);            // anonymous Symbol() id → always added
ilingo.registerStore(libraryStore);             // libraryStore.id = Symbol.for('@me/lib') → idempotent
  • Anonymous id (a fresh Symbol(), the MemoryStore default) — the store is always added, since each Symbol() is unique.
  • Stable id (a Symbol.for('@scope/pkg') set on the store) — idempotent: a no-op (keeping the existing store) if a store with that id is already registered, so re-registration — even from a duplicate package copy — never stacks duplicates. This is how @ilingo/validup and @ilingo/vuelidate register their catalogs: each ships a catalog store keyed by its exported STORE_ID, added with ilingo.registerStore(createMemoryStore()).

Because a namespace is a shared key-space (the walk falls through store-by-store per missing key), registering an app store before a library's catalog lets the app add or override individual keys of that namespace while the library supplies the defaults. Ilingo implements the IIlingo interface — type against IIlingo when you want to accept any orchestrator implementation.

Memory Store

The Memory Store is the default store and is set if no other Store is specified manually.

Loader Store

For browser / SPA apps with code-split locale chunks, LoaderStore lazy-loads translation data via a user-supplied function and caches the result per (locale, namespace):

import { Ilingo, LoaderStore } from 'ilingo';

const ilingo = new Ilingo({
    store: new LoaderStore({
        loader: async (locale, namespace) => {
            // The module default is a translations node (`{ "type": "translations", "data": { ... } }`,
            // or `export default defineTranslations({ ... })` for TS/JS chunks).
            const m = await import(`./locales/${locale}/${namespace}.json`);
            return m.default;
        },
        locales: ['en', 'de', 'fr'],   // optional — answers `getLocales()`
    }),
});

await ilingo.get({ namespace: 'cart', key: 'items', count: 3 });
// First call loads `./locales/en/cart.json`; subsequent calls hit the cache.

Concurrent get()s for the same (locale, namespace) share one loader invocation. Misses (loader returning undefined) are cached too, so the loader isn't re-called for keys it has no answer for.

Invalidation

Stores that cache lookups can implement IInvalidatingStore:

export interface IInvalidatingStore extends IStore {
    invalidate(locale?: string, namespace?: string): void;
    on(event: 'invalidate', listener: (locale?: string, namespace?: string) => void): () => void;
}

Drop scoped cache entries with invalidate(locale?, namespace?)() drops everything, ('en') drops all namespaces for en, ('en', 'app') drops just one namespace. Subscribe to invalidation events via on('invalidate', cb) to react to file changes or manual drops.

Both LoaderStore and FSStore implement this interface. The Vue composable (@ilingo/vue) subscribes automatically — file changes under FSStore({ watch: true }) trigger a re-render without a remount.

Detect via the isInvalidatingStore(store) type guard before subscribing.

FS Store

The FSStore is a Store which access the FileSystem for locating namespace files of different locales.

License

Made with 💚

Published under MIT License.