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

@setu-ts/localization-plugin

v0.11.0

Published

Localization: message catalogues, request locale resolution, a formatter shared with the browser

Readme

@setu-ts/localization-plugin

Message catalogues per locale, request locale resolution, and one formatter shared by the server and the browser.

Overview

  • LocalizationPlugin registers an ILocalizer under CAPABILITIES.LOCALIZATION and validates every catalogue at startup, so a missing translation fails register() rather than a request.
  • A middleware at priority 45 resolves each request's locale — query parameter, cookie, Accept-Language, a tenant default, then the default — and writes it to ctx.request.locale. Every candidate is matched against your supported locales only.
  • localizerFor(ctx) returns the localizer for that locale. Other plugins resolve the same contract without importing this package.
  • The formatter and locale negotiation also ship as the import-free subpath @setu-ts/localization-plugin/format, so a hydrated component or an SDK client formats with the same code the server used. A browser imports that subpath only; the package root is the server plugin.

Zero npm dependencies: plurals, numbers and dates come from the platform's Intl.

Installation

npm install @setu-ts/localization-plugin
# Deno: deno add npm:@setu-ts/localization-plugin

Usage

import { createApplication } from '@setu-ts/kernel';
import { RuntimePlugin } from '@setu-ts/runtime';
import { LocalizationPlugin, localizerFor } from '@setu-ts/localization-plugin';

const app = createApplication({
  plugins: [
    RuntimePlugin(),
    LocalizationPlugin({
      supportedLocales: ['en', 'de'], // the first entry is the default
      catalogues: {
        en: { greeting: 'Hello {name}', items: { one: '{count} item', other: '{count} items' } },
        de: {
          greeting: 'Hallo {name}',
          items: { one: '{count} Artikel', other: '{count} Artikel' },
        },
      },
    }),
  ],
});

app.router.get('/hello/:name', (ctx) => {
  const { t } = localizerFor(ctx);
  return ctx.response.text(
    `${t('greeting', { name: ctx.params.name })} — ${t('items', { count: 3 })}`,
  );
});

await app.start({ port: 3000 });

curl -H 'accept-language: de-AT' localhost:3000/hello/Ada answers Hallo Ada — 3 Artikel, with Content-Language: de.

Catalogues

A catalogue maps a flat key to a message. A message is a string with {name} placeholders, or a plural record keyed by CLDR category (zero, one, two, few, many, other) with other required. The form is chosen with Intl.PluralRules(locale).select(count), so a plural message needs a numeric count:

  • zero is selected only where the locale's rules produce it (Arabic, Latvian, …). In English, count: 0 selects other — write { one: '{count} item', other: '{count} items' }, not a zero form, unless you special-case zero yourself.
  • A form the message lacks falls back to other.

Validation at register() refuses, by name: a malformed or unknown locale tag (Intl would otherwise silently format it in the runtime's default locale), a catalogue for a locale you did not list, a default locale with no catalogue, a malformed message, and a supported locale missing keys the default locale defines. Set allowPartialCatalogues: true to accept the last one with a single warning per locale; the default locale's message is then served for the missing keys — formatted in the REQUEST's locale, so its numbers and dates follow the reader's conventions, exactly as a browser formatting the served catalogue does.

Catalogues kept outside the code load once through a source, still validated the same way:

import { LocalizationPlugin } from '@setu-ts/localization-plugin';
import type { IMessageSource } from '@setu-ts/localization-plugin';

declare const kv: { get(key: string): Promise<string | null> };

const source: IMessageSource = {
  name: 'kv',
  load: async () => JSON.parse((await kv.get('catalogues')) ?? '{}'),
};

LocalizationPlugin({ supportedLocales: ['en', 'de'], source });

How the locale is resolved

The middleware tries, in order, the first source that selects a supported locale:

  1. the locale query parameter (middleware.query, false disables it);
  2. the setu_locale cookie (middleware.cookie, false disables it);
  3. Accept-Language — q-values honoured, de-AT falling back to de, and a * range selecting the first supported locale the client did not exclude with q=0 (so en;q=0, * never selects en). When every locale is excluded the chain continues: the plugin never answers 406;
  4. tenantLocale(tenant), when configured and a tenant is resolved — the tenancy middleware runs at 40, before this one at 45;
  5. the default locale.

A signed-in user's saved preference is not a source here, because session (260) and authentication (300) run later. Apply it with replaceLocale(ctx.request, tag) once you know the user; Content-Language follows, because it is written after the handler returns.

The Accept-Language parse is bounded before any work: the value is cut to 1024 characters before it is split, at most 16 ranges are read, and a range longer than 35 characters is dropped. The same 35-character cap applies to the query parameter and the cookie, and a configured locale longer than that is refused at startup, since no client could select it. A malformed tag is "no match", never an error. Client text never selects a locale you did not configure, and it never reaches a response header.

The operational paths /live, /ready, /health, /metrics, /openapi.json and /docs are skipped (middleware.exclude, [] disables). A reader running before priority 45 — or on an excluded path — sees ctx.request.locale as undefined, and localizerFor answers the default locale.

ctx.request.locale accepts one implicit write per request, like user and tenant; a second plain assignment throws. Use replaceLocale for a deliberate change.

Response headers

Every governed response carries Vary: Accept-Language, plus Cookie while the cookie source is enabled. Both are unconditional, because the response varies by those headers whether or not they won on this request. Vary: Cookie is correct and has a real cost: most CDNs treat it as effectively uncacheable. If you want edge caching, set middleware: { cookie: false } and let users choose through the query parameter or a path instead.

Content-Language carries the final locale after next() resolves, unless already set. A non-throwing refusal inside priority 45 carries it too: a guard's 401/403, a route middleware calling respondWithError, or the catalogue route's 404. A thrown error skips that write when errorHandler is outside priority 45, as its documentation instructs. A refusal before priority 45 (such as the required-tenant rejection at 40) never enters locale middleware and carries none.

Caching

@setu-ts/cache-plugin keys on the resolved locale by default, the way it keys on the tenant, so one locale's cached body is never served to another. That holds wherever the cache runs AFTER the locale is final:

  • a route-level cacheMiddleware always does — route middleware runs after every global one;
  • a global cacheMiddleware must have a higher priority number than the locale middleware — 45 by default, or whatever middleware.priority is set to (as it must already sit above 40 for the tenant);
  • when the locale middleware runs per route instead (middleware.enabled: false), a global cache runs before it, so put the cache on that route, listed after localeMiddleware;
  • a replaceLocale override is reflected only when it runs before the cache lookup — in global middleware, not inside the handler. A route that changes the locale in its handler must not be response-cached.

@setu-ts/cloudflare-plugin's cacheApiMiddleware keys on a URL, which the platform matches with no request headers — Vary cannot separate its entries — so its default key carries the resolved locale as a setu-cache-locale parameter (on the key, never the request), under the same conditions as above — running after the locale middleware, and not reflecting a handler-time replaceLocale. A custom key replaces that and must include ctx.request.locale itself.

Vary protects the caches you do not configure: browsers, proxies and CDNs that honour it.

Rendering paths

Server-rendered views

A component receives finished strings as props; the rendering runtime escapes them.

import { html } from 'hono/html';
import { createApplication } from '@setu-ts/kernel';
import { RuntimePlugin } from '@setu-ts/runtime';
import { renderView, ViewPlugin } from '@setu-ts/view-plugin';
import { LocalizationPlugin, localizerFor } from '@setu-ts/localization-plugin';

const Cart = (props: { readonly title: string; readonly summary: string }) =>
  html`
    <h1>${props.title}</h1>
    <p>${props.summary}</p>
  `;

const app = createApplication({
  plugins: [
    RuntimePlugin(),
    ViewPlugin({ engine: 'hono-html' }),
    LocalizationPlugin({
      supportedLocales: ['en'],
      catalogues: {
        en: { cart: 'Cart', summary: { one: '{count} item', other: '{count} items' } },
      },
    }),
  ],
});

app.router.get('/cart', (ctx) => {
  const { t } = localizerFor(ctx);
  return renderView(ctx, Cart, { title: t('cart'), summary: t('summary', { count: 2 }) });
});

React Router (SSR and hydration)

A loader does not see the kernel's request context, so hand it the request's localizer through an application-declared context key:

import { contextKeyFor, ReactRouterPlugin } from '@setu-ts/react-router-plugin';
import type { RouterLoadContext } from '@setu-ts/react-router-plugin';
import type { ILocalizer } from '@setu-ts/common';
import { localizerFor } from '@setu-ts/localization-plugin';

// app/lib/context-keys.server.ts
export const localizerContext = contextKeyFor<ILocalizer | null>('app.localizer', null);

// setu.config.ts
export const ssr = ReactRouterPlugin({
  serverBuildPath: './build/server/index.js',
  populateLoadContext: (ctx, context) => context.set(localizerContext, localizerFor(ctx)),
});

// app/routes/cart.tsx
export function loader({ context }: { context: RouterLoadContext }) {
  const localizer = context.get(localizerContext);
  return { locale: localizer?.locale ?? 'en', title: localizer?.t('cart') ?? 'Cart' };
}

Strings formatted in the loader need nothing on the client. A hydrated component that formats runtime values itself fetches its locale's catalogue (below) and calls the shared formatter.

Browser and SDK clients

Expose the catalogues and format with the subpath, which imports nothing at runtime:

import { LocalizationPlugin } from '@setu-ts/localization-plugin';

LocalizationPlugin({
  supportedLocales: ['en', 'de'],
  catalogues: { en: { greeting: 'Hello {name}' }, de: { greeting: 'Hallo {name}' } },
  exposeCatalogues: { basePath: '/i18n' }, // GET /i18n/de → { locale, messages }
  timeZone: 'Europe/Berlin',
});
import { format, negotiateLocale } from '@setu-ts/localization-plugin/format';
import type { MessageCatalogue } from '@setu-ts/common';

const supported = ['en', 'de'];
const locale = negotiateLocale(navigator.languages, supported) ?? 'en';
const { messages } = await (await fetch(`/i18n/${locale}`)).json() as {
  readonly messages: MessageCatalogue;
};

const greeting = messages.greeting;
const text = greeting === undefined ? '' : format(greeting, { name: 'Ada' }, locale, {
  timeZone: 'Europe/Berlin',
});

The route serves only supported locales, each overlaid on the default locale's messages so a partial catalogue falls back exactly as t() does. Anything else is a 404 with a fixed detail. Its default Cache-Control is private, max-age=3600: browser-cached, never shared, because a session with rolling or idleTimeoutMs refreshes its cookie on every response and a shared cache must not store that. Set cacheControl: 'public, max-age=3600' when no session cookie reaches the route.

Formatting

Numbers use Intl.NumberFormat(locale) defaults; dates use Intl.DateTimeFormat(locale) defaults plus the configured timeZone. There is no currency, percent, dateStyle or per-placeholder option. Format those values with Intl yourself and pass the resulting string:

import { format } from '@setu-ts/localization-plugin/format';

const price = new Intl.NumberFormat('de', { style: 'currency', currency: 'EUR' }).format(19.95);
const label = format('Total: {price}', { price }, 'de');

{name} is replaced by values[name]: a number with Intl.NumberFormat, a Date with Intl.DateTimeFormat, a string verbatim, null/undefined as nothing. A placeholder with no value is left as written.

The formatter escapes nothing. It substitutes text; the renderer escapes it. An html template or JSX escapes the result, a hand-written string concatenated into HTML does not — the same rule as for IViewEngine.render.

Shared code, not identical output. The server and the browser run the same formatter, but Intl output depends on each runtime's locale data, and a Date formats in the runtime's own time zone unless one is given. Set the plugin's timeZone and pass the same value to format in the browser when dates must agree.

Missing keys

t() for a key no catalogue defines answers the key itself and logs one warning per key, so a translation gap never fails a request. At most 256 distinct keys are tracked. Set onMissing: 'throw' in tests and CI to fail loudly instead.

Options

| Option | Default | Description | | ------------------------ | --------------- | ------------------------------------------------------------------------------------------------------------------ | | supportedLocales | — | BCP 47 tags; the first is the default. Required. | | catalogues | — | Catalogues keyed by tag. Exactly one of this and source. | | source | — | An IMessageSource loaded once at register(). | | allowPartialCatalogues | false | Accept locales missing default keys, with one warning each. | | onMissing | 'key' | 'throw' throws MissingMessageError for an unknown key. | | timeZone | runtime zone | IANA zone Date values are formatted in; validated at startup. | | tenantLocale | — | (tenant) => tag, consulted after Accept-Language. | | middleware.enabled | true | false registers no global middleware; use localeMiddleware. | | middleware.priority | 45 | After tenant (40), before logging (50). | | middleware.query | 'locale' | Query parameter source; false disables it. | | middleware.cookie | 'setu_locale' | Cookie source; false disables it and drops Cookie from Vary. | | middleware.exclude | six probe paths | Paths skipped entirely; [] disables exclusion. | | exposeCatalogues | off | { basePath, cacheControl? } serves GET <basePath>/:locale; cacheControl defaults to private, max-age=3600. |

Health Indicator

The plugin registers a localization indicator reporting the supported locale count, the default locale and the catalogue source. It is always up: after register() there is no backend to probe.

Exports

@setu-ts/localization-plugin

| Export | Kind | | --------------------------- | --------- | | localeMiddleware | function | | LocalizationPlugin | function | | localizerFor | function | | MissingMessageError | class | | MissingPluralCountError | class | | UnsupportedLocaleError | class | | IMessageSource | interface | | LocaleMiddlewareOptions | interface | | LocalizationPluginOptions | type |

@setu-ts/localization-plugin/format

| Export | Kind | | --------------------- | --------- | | format | function | | negotiateLocale | function | | parseAcceptLanguage | function | | AcceptLanguage | interface | | FormatOptions | interface | | FormatValues | type |

Generated from the package barrel by pnpm run docs:exports; pnpm run check:docs fails when it drifts.

Full API

Every export and option is documented in PUBLIC_API.md.