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

js-lingo

v0.0.3

Published

A small, type-safe i18n facade for vanilla JS, Web Components and React

Readme

js-lingo

A lightweight, type-safe i18n facade for TypeScript — plain values instead of a message DSL, swappable strategies instead of a built-in framework, and Intl as the one thing that is deliberately not configurable.

const datePickerTexts = createNamespace({
  key: "date-picker",
  defaults: {
    today: "Today",
    dateRange: (p: { from: Date; to: Date }, i18n) =>
      `${i18n.formatDateTime(p.from)} – ${i18n.formatDateTime(p.to)}`,
  },
});

const i18n = createI18n();
i18n.getText(datePickerTexts, "today"); // "Today"
i18n.getText(datePickerTexts, "dateRange", { from, to }); // typed params, checked at compile time
i18n.formatNumber(1234.5); // Intl, in the active locale

No setup was needed for the code above: with zero configuration, the active locale follows <html lang> (on the client) and every namespace carries its own default texts. Everything beyond that — where the locale comes from, where translations come from — is a strategy you plug in.

Why a facade?

Most i18n libraries want to be your internationalization layer. This one fronts it:

  • A fixed Intl core. formatNumber, numberFormat, formatDateTime, dateTimeFormat — thin, cached wrappers over Intl.NumberFormat and Intl.DateTimeFormat. Using Intl (directly or indirectly) is the only reasonable choice in JavaScript, so this part is intentionally not configurable.
  • Two swappable strategies. Where am I? (localeSource) and what do texts resolve to? (textSource). The built-in implementations have no privileged status — an adapter for i18next, FormatJS, or your backend plugs into the exact same slots.
  • One decoration slot. middlewares wrap every resolution — pseudo-localization, missing-text reporting, request rewriting — regardless of which source is active.

The config is the whole architecture:

type I18nConfig = Readonly<{
  localeSource?: LocaleSource; // strategy 1 — default: <html lang> monitor
  textSource?: TextSource; // strategy 2 — default: none (namespace defaults apply)
  middlewares?: TextMiddleware[]; // decoration — index 0 is outermost
}>;

Resolution order: middlewares → textSource → namespace defaults → bare key.

There is no library-owned global state. You create instances with createI18n and distribute them yourself — via argument, framework DI (React context and friends), or the Context Community Protocol for custom elements (see below).

Three roles, strictly separated

The API is shaped around who does what:

Component authors ship a namespace whose defaults define both the type (keys and parameter shapes) and the texts of last resort. A component works in any app with zero cooperation — untranslated, but never broken:

export const datePickerTexts = createNamespace({
  key: "date-picker",
  defaults: {
    today: "Today",
    dateRange: (p: { from: Date; to: Date }, i18n) =>
      `${i18n.formatDateTime(p.from)} – ${i18n.formatDateTime(p.to)}`,
  },
});

Translation authors declare and export bundles. Their job ends there — how bundles reach whatever text source an app uses is none of their concern:

export const datePickerGerman = bundleTexts({
  de: [
    fullTexts(datePickerTexts, {
      today: "Heute",
      dateRange: (p, i18n) => `${i18n.formatDateTime(p.from)} – ${i18n.formatDateTime(p.to)}`,
    }),
  ],
});

texts(namespace, {...}) is the normal, partial form — anything missing falls back to the defaults. fullTexts(namespace, {...}) additionally makes the compiler verify completeness; use it for translations that claim to cover everything, e.g. the locale bundles a component library ships. bundleTexts is a type-checking identity so that errors surface at the declaration site instead of at a distant consumer.

Apps collect bundles into a source — or replace the source entirely:

const i18n = createI18n({
  textSource: defaultTextSource({
    textBundles: [
      datePickerGerman, // available immediately
      fetchTenantTexts(), // a promise — registers when it settles
      () => import("./locales/fr.js").then((m) => m.french), // a thunk — loaded on first use
    ],
    fallbackLocales: ["en"],
  }),
});

While an async bundle is in flight, the namespace defaults show; when it lands, the instance's onChange fires and subscribed hosts re-render. Nothing is ever broken, only briefly untranslated.

The facade at a glance

createI18n returns the dynamic instance — its locale follows the localeSource. i18n.localize("de") returns a memoized sibling statically bound to de, sharing the same pipeline, caches, and change channel; sibling.localize() leads back to the dynamic instance.

i18n.getLocale(); // active locale
i18n.onChange(() => rerender()); // fires on locale AND text changes
const t = i18n.bindTexts(datePickerTexts);
t("today"); // scoped shorthand
t(otherTexts, "someKey"); // fully-qualified escape hatch

Miss policy is owned by the facade: a resolver returns a string (the empty string is a valid translation!) or undefined for "miss". Because defaults live on the namespace, a bare key can only ever appear for keys that have no default — which the getText overloads already rule out at compile time.

Locale sources

The default locale source watches <html lang> on the client (live, via MutationObserver). Where there is no DOM, you tell it what to do instead:

createI18n({
  localeSource: defaultLocaleSource({
    serverSide: () => requestContext.getStore()?.locale ?? "en-US", // per-request
    defaultLocale: "en-US",
  }),
});

serverSide accepts a fixed tag (static builds), a getter (per-request, e.g. via AsyncLocalStorage), or a full LocaleSource with its own change channel. Any other behavior — language negotiation, user preferences, query parameters — is a LocaleSource you write yourself: it is just { getLocale, onChange? }.

Note the separation this enforces: fallback never changes where the user is — if a German translation is missing and English text substitutes, dates and numbers still format German. Cross-language fallback is a text concern, not a locale concern.

Text sources, combinators, adapters

A text source is { resolve, onChange? } where resolve returns a string or undefined. That is the entire integration contract. An i18next adapter, sketched:

const i18n = createI18n({
  localeSource: {
    getLocale: () => i18next.language,
    onChange: (listener) => {
      i18next.on("languageChanged", listener);
      return () => i18next.off("languageChanged", listener);
    },
  },
  textSource: {
    resolve: ({ locale, namespace, key, params }) =>
      i18next.exists(`${namespace.key}:${key}`, { lng: locale })
        ? i18next.t(`${namespace.key}:${key}`, { lng: locale, ...(params as object) })
        : undefined, // real miss detection — falls through to the namespace defaults
    onChange: (listener) => {
      i18next.store.on("added", listener); // async resources -> re-render
      return () => i18next.store.off("added", listener);
    },
  },
});

Middlewares

Middlewares decorate the whole pipeline — they see texts from any source and from namespace defaults, including nested lookups made by translation functions:

createI18n({
  textSource,
  middlewares: [
    // pseudo-localization for UI testing
    (request, context, next) => (pseudo ? toPseudo(next()) : next()),
    // hard-miss reporting (`undefined` = neither source nor defaults had the key)
    (request, context, next) => {
      const resolved = next();
      if (resolved === undefined) report(request);
      return resolved;
    },
    // request rewriting
    (request, context, next) => next(request.locale === "nb" ? { locale: "no" } : undefined),
  ],
});

Rule of thumb for the two decoration layers: middlewares wrap the pipeline (everything, uniformly), source combinators wrap one source (and see its misses directly — the right place for "translated vs. defaulted" coverage reporting).

Custom elements (Lit-friendly, Lit-free)

The companion module distributes instances to web components via the Context Community Protocol — dependency-free, interoperable with any protocol-compliant counterpart such as @lit/context (consumers and providers only share the context-request event and the exported i18nContext key).

Inside a component, the reactive controller is an I18n and re-renders its host on locale or text changes:

class FancyDatePicker extends LitElement {
  private i18n = i18nController(this);

  render() {
    return html`<button>${this.i18n.getText(datePickerTexts, "today")}</button>`;
  }
}

It resolves its instance in three stages — explicit argument, then a provider up the tree, then an internal zero-config fallback — so the component works in every app, provider or not.

The app provides its instance declaratively or imperatively:

<i18n-provider .i18n="${appI18n}">
  <fancy-date-picker></fancy-date-picker>
</i18n-provider>
provideI18n(document.body, appI18n); // app-wide

Late providers are handled gracefully: a value-less <i18n-provider> does not claim requests (an outer provider may serve meanwhile) but remembers subscribers and answers as soon as its value arrives — consumers keep the latest answer.

Server-side rendering

The core has no DOM dependency. Instances are cheap; the module-global Intl formatter cache and your text sources are shared across them, so per-request instances are a natural fit:

const i18nForRequest = createI18n({
  localeSource: { getLocale: () => request.locale },
  textSource: sharedTextSource,
});

Alternatively, keep one shared instance and use defaultLocaleSource({ serverSide }) with an AsyncLocalStorage-backed getter. The custom-element module also imports cleanly without a DOM (element registration is skipped).

Type safety

The namespace defaults are the single source of truth. From them, the compiler derives everything:

i18n.getText(datePickerTexts, "today"); // ok — static key, no params
i18n.getText(datePickerTexts, "today", { x: 1 }); // error — static keys take no params
i18n.getText(datePickerTexts, "dateRange"); // error — params required
i18n.getText(datePickerTexts, "dateRange", { from: 1 }); // error — wrong param shape
i18n.getText(datePickerTexts, "tdoay"); // error — unknown key
partialTexts(datePickerTexts, { today: "Heute" }); // ok — partial by design
fullTexts(datePickerTexts, { today: "Heute" }); // error — completeness required

No codegen, no message DSL, no string parsing: translations are plain strings and plain functions.

Design principles

  • The config contains strategies and middlewares — nothing else. It neither knows nor privileges any concrete implementation; the built-in source and locale monitor plug into the same slots an adapter would.
  • No configurable global state. The only module-level state is a deterministic Intl formatter cache and an immutable zero-config fallback instance.
  • Defaults make components self-sufficient. A component library needs no app cooperation to function, and no registration step for its source language.
  • Found-locale formatting. A translation found via fallback formats with the locale it was found in; the user's locale governs everything else.
  • Pure data at the boundaries. Namespaces, bundles, and requests are frozen values; behavior lives in the facade, sources, and combinators around them.

Development

npm test               # vitest, node + jsdom environments
npm test -- --coverage # enforced thresholds; currently at 100 % on both modules

License

MIT