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

@shadow-js/i18n

v1.0.4

Published

Typed translations with ICU messages, lazy catalogs and server rendering that hydrates cleanly

Readme

@shadow-js/i18n

npm CI License: MIT Node.js TypeScript

Internationalization for ShadowJS: typed message keys and arguments, ICU MessageFormat, fine-grained locale switching, lazy catalogs, server rendering that hydrates without a mismatch, and a Dusk middleware. Plural rules, numbers, dates, lists, relative time and display names come from the platform Intl; the only dependency is @shadow-js/core (a peer).

Install

npm install @shadow-js/i18n

@shadow-js/i18n has no dependency on a renderer: it works with @shadow-js/web (or any renderer that accepts the ShadowJS children shape) through your own app, and with @shadow-js/dusk through a structural middleware type, never an import.

Quick start

A catalog is a nested object of ICU messages. The default locale's catalog types every key and argument; other locales are loaded on first use. (The catalogs are TypeScript modules so the example runs without a bundler; a JSON file works the same, see Typed catalogs.)

export default {
  hello: "Bonjour {name}",
  cart: { items: "{count, plural, =0 {Votre panier est vide} one {# article} other {# articles}}" },
  terms: "Lisez les <b>conditions</b>.",
} as const;
import { createI18nWithIcu } from "@shadow-js/i18n";

export const i18n = createI18nWithIcu({
  defaultLocale: "en",
  locales: ["en", "fr"],
  messages: {
    en: {
      hello: "Hello {name}",
      cart: { items: "{count, plural, =0 {Your cart is empty} one {# item} other {# items}}" },
      terms: "Read the <b>terms</b>.",
    },
  },
  loaders: { fr: () => import("./messages/fr") },
});

I18nProvider makes the instance available; useI18n() returns it. t reads the locale signal, so {t("hello", { name })} in JSX re-runs that one text node when the locale or name changes.

import { I18nProvider, useI18n } from "@shadow-js/i18n";
import { i18n } from "./i18n";

export function Cart(props: { name: string; count: number }) {
  const { t } = useI18n<typeof i18n>();
  return (
    <section>
      <h1>{t("hello", { name: props.name })}</h1>
      <p>{t("cart.items", { count: props.count })}</p>
      <p>{t.rich("terms", { b: (chunks) => <b>{chunks}</b> })}</p>
    </section>
  );
}

export function Page() {
  return (
    <I18nProvider i18n={i18n}>
      <Cart name="Ada" count={0} />
    </I18nProvider>
  );
}

In a test, give each case its own instance with fork() and switch the locale like a user would. The text node is rewritten in place:

import { flushSync } from "@shadow-js/core";
import { I18nProvider } from "@shadow-js/i18n";
import { cleanup, render } from "@shadow-js/testing/dom";
import { afterEach, describe, expect, it } from "vitest";
import { Cart } from "./app";
import { i18n } from "./i18n";

afterEach(cleanup);

describe("the quick start", () => {
  it("switches to French without remounting", async () => {
    const local = i18n.fork();
    const view = render(() => (
      <I18nProvider i18n={local}>
        <Cart name="Ada" count={2} />
      </I18nProvider>
    ));
    const heading = view.container.querySelector("h1");
    const text = heading?.firstChild;
    expect(heading?.textContent).toBe("Hello Ada");

    await local.setLocale("fr");
    flushSync();
    expect(heading?.textContent).toBe("Bonjour Ada");
    expect(view.container.querySelector("p")?.textContent).toBe("2 articles");
    expect(view.container.querySelector("h1")).toBe(heading);
    expect(heading?.firstChild).toBe(text);
  });
});

Optional features

The core createI18n (from @shadow-js/i18n) holds catalogs, t, setLocale, lazy loading and the components. Four capabilities are opt-in features, each in its own subpath, listed in the features option:

  • formatting (@shadow-js/i18n/format): formatNumber, formatDate, formatRelative, formatList, formatDisplayName and the Formatted* components.
  • localeRouting (@shadow-js/i18n/routing): the prefix option, localePath and routing.
  • persistence (@shadow-js/i18n/persistence): the persist option, with cookieStrategy and storageStrategy.
  • detection (@shadow-js/i18n/detect): the detect option, with detectLocale and readCookie.

createI18nFull (@shadow-js/i18n/full) is createI18nWithIcu with all four features listed; allFeatures is the frozen list it uses. Using an option or method without its feature throws a TypeError that names the feature and its import. ICU arguments and plurals need none of them.

Typed catalogs

Keys are the dotted paths of the default locale's catalog; the arguments of each message are parsed out of its ICU text at the type level. Declare the catalog inline (as above) or as const.

import { i18n } from "./i18n";

i18n.t("hello", { name: "Ada" });
i18n.t("cart.items", { count: 3 });
i18n.t.rich("terms", { b: (chunks) => chunks });
i18n.scoped("cart")("items", { count: 1 });
void i18n.setLocale("fr");

// @ts-expect-error a message with arguments needs them
i18n.t("hello");
// @ts-expect-error count is a plural, so a number
i18n.t("cart.items", { count: "3" });
// @ts-expect-error no such key
i18n.t("cart.itemz", { count: 1 });
// @ts-expect-error every tag needs a handler
i18n.t.rich("terms", {});
// @ts-expect-error de is not one of the locales
void i18n.setLocale("de");

A catalog imported from a JSON file is typed with plain string values by TypeScript: its keys are still checked and its arguments become Record<string, unknown>. Declare the data as const to get the full check. To make useI18n() and <Trans> typed without a type argument, register the instance once: declare module "@shadow-js/i18n" { interface Register { i18n: typeof i18n } }.

ICU MessageFormat

Arguments, plural (zero one two few many other, =N, offset:N, #), selectordinal, select, number and date/time with named styles or :: skeletons, nested messages and rich-text tags.

| Syntax | Example | | --- | --- | | plural | {n, plural, =0 {none} one {# item} other {# items}} | | ordinal | {p, selectordinal, one {#st} two {#nd} few {#rd} other {#th}} | | select | {g, select, f {She} m {He} other {They}} | | number | {n, number}, {n, number, percent}, {n, number, ::currency/EUR .00} | | date | {d, date, short}, {d, date, ::yMMMd}, {d, time, ::Hm} | | tags | Read <b>this</b>, <br/> |

Escaping follows ICU: '' is an apostrophe, '{' quotes a brace, '<b>' quotes a tag, a lone ' is literal, # is special only inside a plural. A skeleton token that is not supported is a syntax error with a position, never silently ignored. Message text and argument values are always text: tags map to functions in t.rich and <Trans>, and there is no HTML path.

import { createI18nWithIcu } from "@shadow-js/i18n";
import { describe, expect, it } from "vitest";

function format(message: string, values: object, locale = "en"): string {
  return createI18nWithIcu({
    defaultLocale: locale,
    locales: [locale],
    messages: { [locale]: { m: message } },
    timeZone: "UTC",
    onError: (error) => {
      throw error;
    },
  }).translate("m", values);
}

describe("ICU", () => {
  it("plural categories follow the locale of the message", () => {
    const six = "{n, plural, zero {zero} one {one} two {two} few {few} many {many} other {other}}";
    expect([0, 1, 2, 3, 11, 100].map((n) => format(six, { n }, "ar"))).toEqual([
      "zero",
      "one",
      "two",
      "few",
      "many",
      "other",
    ]);
    expect([1, 2, 5].map((n) => format(six, { n }, "ru"))).toEqual(["one", "few", "many"]);
  });

  it("numbers, dates and escaping", () => {
    expect(format("{n, number, ::currency/USD}", { n: 5 })).toBe("$5.00");
    expect(format("{d, date, ::yMMMd}", { d: Date.UTC(2024, 2, 5) })).toBe("Mar 5, 2024");
    expect(format("It''s '{'literal'}' and <b>{n}</b>", { n: 2 })).toBe("It's {literal} and 2");
  });
});

Lazy loading, fallbacks and races

loaders fetch a catalog at the first setLocale, load or ready that needs it. A key missing from the active catalog is looked up along the fallback chain (pt-BR to pt, then fallbackLocale, then the default locale), and is formatted with the rules of the locale it came from. setLocale loads first, then switches, updates <html lang dir> and persists; the last call wins, a failed load keeps the old locale and can be retried, and the promise resolves with the locale that ended up active. transition: "suspend" switches at once instead of waiting; text then shows from the chain until the catalog arrives. onMissing supplies text for a key found nowhere, onError receives every recoverable problem, and validate: true checks the static catalogs in development.

Server rendering with Dusk

On a server, use one fork(locale) per request. createI18nMiddleware picks the locale (URL prefix, cookie, Accept-Language), loads its catalog, stores the fork for getEventI18n(event) in server code, sets <html lang dir>, Content-Language and Vary, and can write a cookie or redirect to the canonical URL. It is typed against a structural I18nRequestEvent that a Dusk RequestEvent satisfies.

import { createI18nMiddleware } from "@shadow-js/i18n";
import type { HandlerOptions } from "@shadow-js/dusk/server";
import { i18n } from "./i18n";

const options: Omit<HandlerOptions, "manifest"> = {
  security: { allowedHosts: ["example.com"] },
  middleware: [
    createI18nMiddleware(i18n, {
      strategy: ["url", "cookie", "accept-language"],
      cookie: { name: "locale" },
      redirect: true,
    }),
  ],
};

export default options;

The middleware's path filter (exclude, include) decides which requests it handles: by default /api, Dusk's /_dusk endpoints and paths with a file extension are left alone, so redirect: true never touches them. The rest of the request runs inside an AsyncLocalStorage context that carries the fork: <I18nProvider i18n={i18n}> provides the request's fork on the server (and the app instance in the browser), useI18n() finds it without a provider, and tryGetRequestI18n() returns it, all without importing @shadow-js/dusk/server (which Dusk's import protection refuses in client code). The outermost provider in the browser also checks that the instance starts in the locale the server rendered and re-reads the page state when it does not. To start the browser in the server's locale with the server's catalogs, embed the snapshot with i18nHeadTags(request, { path, origin, state: true }) (or renderI18nStateScript in a custom shell) and read it with readI18nState(). createLocaleRouting and i18n.localePath build /fr/about style URLs (prefix is "as-needed", "always" or "never").

Detection, persistence and right-to-left

detect: true (the detection feature) picks the first locale from the URL prefix, a cookie, storage and navigator.languages; persist (the persistence feature) takes "cookie", "localStorage", an options object or your own { read, write }. createI18nWithIcu and createI18nFull take format: { isolation } ("auto", "always" or "never") to wrap interpolated strings in U+2068 and U+2069; "auto" wraps only in right-to-left locales. intlSupport() reports which Intl constructors the runtime has. detectLocale, parseAcceptLanguage, negotiateLocale and matchLocale are pure functions. i18n.dir() and localeDirection give "ltr" or "rtl", and the primary instance keeps <html lang dir> in step.

Testing and catalog tooling

Use fork() or a fresh createI18n per test, inject timeZone and now, throw from onError, and pseudo-localize to find hard-coded strings. Validation and key extraction are pure functions, so a test can fail CI, and the shadow-i18n command (check, extract, export, import) does the same on a folder of catalogs.

import { compareKeys, createI18nWithIcu, extractMessages, flattenCatalog } from "@shadow-js/i18n";
import { pseudoLocalize, validateCatalogs } from "@shadow-js/i18n/icu";
import { describe, expect, it } from "vitest";

const en = { greet: "Hello {name}", bye: "Bye", old: "Old" } as const;
const fr = { greet: "Bonjour {name}", bye: "Au revoir", old: "Vieux" } as const;

describe("catalog tooling", () => {
  it("validateCatalogs compares every locale with the base", () => {
    expect(validateCatalogs({ en, fr }).ok).toBe(true);
    const broken = validateCatalogs({ en, fr: { greet: "Bonjour", bye: "x" } });
    expect(broken.issues.map((issue) => `${issue.kind}:${issue.key}`)).toEqual([
      "missing-key:old",
      "argument-mismatch:greet",
    ]);
  });

  it("extractMessages and compareKeys find missing and unused keys", () => {
    const usage = extractMessages('t("greet", { name }); t("typo");');
    const result = compareKeys(flattenCatalog(en).keys(), usage);
    expect(result.missing.map((key) => key.key)).toEqual(["typo"]);
    expect(result.unused).toEqual(["bye", "old"]);
  });

  it("pseudoLocalize accents text and keeps arguments", () => {
    expect(pseudoLocalize({ greet: "Hello {name}" })).toEqual({ greet: "[Ĥéļļó~~ {name}]" });
    const pseudo = createI18nWithIcu({
      defaultLocale: "en",
      locales: ["en", "en-XA"],
      messages: { en, "en-XA": pseudoLocalize(en) },
      locale: "en-XA",
      syncDocument: false,
    });
    expect(pseudo.translate("bye")).toBe("[Ɓýé~]");
  });
});

Use a tag no real language owns for the pseudo locale (en-XA): ps, for example, is Pashto.

API

Instance: createI18n (plain text; compile for syntax), createI18nWithIcu (ICU compiler passed), I18n, I18nBase, I18nOptions, I18nState, BaseCatalog, CompiledMessage, MessageCompiler, I18nFeature, I18nFeatureName, IcuI18nOptions. Features: @shadow-js/i18n/format (formatting), @shadow-js/i18n/routing (localeRouting), @shadow-js/i18n/persistence (persistence), @shadow-js/i18n/detect (detection), @shadow-js/i18n/full (createI18nFull, allFeatures). Runtime: intlSupport, IntlSupport, createIntlCache, IntlCache, readCookie, DEFAULT_DETECT_ORDER. Components and hooks: I18nProvider, I18nContext, useI18n, Trans, FormattedNumber, FormattedDate, FormattedRelative, FormattedList, LocaleSwitcher, createLocaleSwitcher, Register. ICU: parseMessage, printMessage, formatToString, formatToParts, parseNumberSkeleton, parseDateSkeleton, PARSE_LIMITS, MessageSyntaxError, I18nError. Catalogs and tooling: validateCatalogs, flattenCatalog, messageSignature, extractMessages, compareKeys, findDuplicateKeys, KeySource, DuplicateKey, pseudoLocalize. Locale and URLs: detectLocale, parseAcceptLanguage, negotiateLocale, matchLocale, localeDirection, createLocaleRouting, cookieStrategy, storageStrategy. Server: createI18nMiddleware, getEventI18n, i18nEventKey, i18nHeadTags, readI18nState, renderI18nStateScript, serializeI18nState, parseI18nState. Types: Translator, MessageKey, MessageArgs, MessageTags, Messages, WidenMessages, RichChunk.

Links

Production error codes

A production build keeps a short code instead of the development explanation, so message text never reaches user bundles. Search this table for the code. In the explanation, … stands for a value the development message names. A code followed by a word names the option, feature or member at fault.

| Code | Development explanation | | --- | --- | | I18N_CACHE | createIntlCache: the capacity must be a whole number of at least 0, got …. / a cache size must be a finite number, got …. | | I18N_CATALOG_1 | "…" is an accessor; catalogs hold plain data. | | I18N_CATALOG_2 | "…" is nested deeper than …. / the catalog is nested deeper than 32. | | I18N_CATALOG_3 | the key "…" is defined twice. | | I18N_CATALOG_4 | "…" has an empty key. | | I18N_CATALOG_5 | the catalog has more than … messages. | | I18N_CATALOG_6 | a catalog must be an object. | | I18N_CATALOG_7 | "…" must be a string or an object, got …. | | I18N_COOKIE_1 | "…" is not a valid cookie attribute. | | I18N_COOKIE_2 | maxAge must be a finite number of seconds, got …. | | I18N_COOKIE_3 | "…" is not a valid cookie name. | | I18N_COOKIE_4 | sameSite must be "lax", "strict" or "none", got …. | | I18N_DEFAULT | defaultLocale "…" is not in locales. | | I18N_FEATURES | the features option must be an array of features (for example [formatting]). | | I18N_FEATURE_1 | features[…] is not an i18n feature: pass formatting, localeRouting, persistence or detection from their @shadow-js/i18n subpaths. | | I18N_FEATURE_2 | … needs the … feature: import { … } from "@shadow-js/i18n/…" and pass createI18n({ features: […] }), or use createI18nFull from "@shadow-js/i18n/full". | | I18N_FEATURE_3 | two different "…" features were passed. | | I18N_LOADER_1 | the module for "…" has no default export. | | I18N_LOADER_2 | the loader for "…" did not return an object. | | I18N_LOCALE | "…" is not one of the supported locales (…). | | I18N_NO_1 | no i18n instance on this request; add createI18nMiddleware() to the middleware list. | | I18N_NO_2 | useI18n() needs an above it. | | I18N_ORIGIN_1 | "…" must be an http(s) URL. | | I18N_ORIGIN_2 | "…" is not an absolute URL. | | I18N_STATE_1 | the catalog for "…" must be an object. | | I18N_STATE_2 | "…" is not a safe element id. | | I18N_STATE_3 | the state is not valid JSON: … | | I18N_STATE_4 | the catalog for "…" has more than … messages. | | I18N_STATE_5 | the nonce contains unexpected characters. | | I18N_STATE_6 | the state must be an object. | | I18N_STATE_7 | the state is larger than … characters. | | I18N_STATE_8 | "….…" must be a string. | | I18N_STATE_9 | the hydration state is not a version 1 snapshot. / the state is not a version 1 snapshot. |

The message reads [shadow-i18n INVALID_STATE] I18N_STATE_1: the first word is the stable I18nError code, the second the code of the exact problem. A MessageSyntaxError already says what is wrong with its syntaxCode.

Dependencies

  • Runtime: none.
  • Peers: @shadow-js/core ^1.0.3: reactive locale state.

Types

  • Names from the root: CompiledMessage, MessageArgs, BaseCatalog, KeySource, DetectOptions, I18nErrorCode.
  • Type tests in test-types/ run with npm run typecheck.

Support

  • Node.js >=22.12.0. ESM only: use import; require is not supported.
  • Browsers: current evergreen releases.
  • Versioning: every @shadow-js/* package releases together at one version. Upgrade them as one set.
  • Documentation: ShadowJS documentation.

Using, changing and contributing

  • Use the package (a person or a coding agent that builds with it): this README, and SKILL.md, the short page an agent reads first.
  • Change the package (fix a bug, add a feature): AGENTS.md says how the repository is laid out, how to implement and test a change, and how a pull request is reviewed. It is written for people and coding agents alike.
  • Take part: CONTRIBUTING.md is the short guide; SECURITY.md is for vulnerabilities.