@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

OptionDefaultDescription
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().
allowPartialCataloguesfalseAccept locales missing default keys, with one warning each.
onMissing'key''throw' throws MissingMessageError for an unknown key.
timeZoneruntime zoneIANA zone Date values are formatted in; validated at startup.
tenantLocale—(tenant) => tag, consulted after Accept-Language.
middleware.enabledtruefalse registers no global middleware; use localeMiddleware.
middleware.priority45After 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.excludesix probe pathsPaths skipped entirely; [] disables exclusion.
exposeCataloguesoff{ 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

ExportKind
localeMiddlewarefunction
LocalizationPluginfunction
localizerForfunction
MissingMessageErrorclass
MissingPluralCountErrorclass
UnsupportedLocaleErrorclass
IMessageSourceinterface
LocaleMiddlewareOptionsinterface
LocalizationPluginOptionstype

@setu-ts/localization-plugin/format

ExportKind
formatfunction
negotiateLocalefunction
parseAcceptLanguagefunction
AcceptLanguageinterface
FormatOptionsinterface
FormatValuestype

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.

API reference

Localization Plugin (@setu-ts/localization-plugin)

Message catalogues per locale, request locale resolution, and a formatter shared with the browser, shipped in Milestone 103. The plugin registers an ILocalizer under CAPABILITIES.LOCALIZATION, validates every catalogue at register(), and resolves each request’s locale into IRequest.locale. Zero npm dependencies: plurals, numbers and dates come from Intl.

Registration

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'],
      catalogues: { en: { greeting: 'Hello {name}' }, de: { greeting: 'Hallo {name}' } },
    }),
  ],
});

app.router.get('/', (ctx) => ctx.response.text(localizerFor(ctx).t('greeting', { name: 'Ada' })));

Options

LocalizationPluginOptions requires exactly one of catalogues and source — supplying both or neither is a compile error, and a JavaScript caller is refused at construction.

OptionTypeDefaultBehavior
supportedLocalesreadonly string[]—BCP 47 tags, canonicalized; the first is the default. Refused when empty, malformed, duplicated, longer than 35 characters (no client could select it), or unknown to the runtime’s Intl.
cataloguesRecord<string, MessageCatalogue>—Static catalogues keyed by tag, validated at register().
sourceIMessageSource—Loaded once at register() (a rejection fails startup), validated identically; the health indicator reports injected[:name].
allowPartialCataloguesbooleanfalseAccept a locale missing keys the default defines: one warning per locale at register(), the default’s message served for the gap.
onMissing'key' | 'throw''key'A key no catalogue defines answers the key (warned once per key, at most 256 tracked), or throws MissingMessageError.
timeZonestringruntime zoneIANA zone Date values are formatted in; refused at construction when Intl does not recognize it.
tenantLocale(tenant: ITenant) => string | undefined—The tenant’s default, consulted after Accept-Language; matched like any candidate. A throw propagates (application code, not client input).
middleware.enabledbooleantruefalse registers no global middleware; attach localeMiddleware(...) yourself.
middleware.prioritynumber45After tenant resolution (40), before logging (50). Must be an integer.
middleware.querystring | false'locale'The query parameter source; false disables it.
middleware.cookiestring | false'setu_locale'The cookie source; false disables it and drops Cookie from Vary.
middleware.excludereadonly PathPattern[]six probe pathsSkipped entirely — no resolution, no headers. Default /live, /ready, /health, /metrics, /openapi.json, /docs; [] disables.
exposeCatalogues{ basePath; cacheControl? }offRegisters GET <basePath>/:locale. basePath must be one or more plain path segments (letters, digits, ., _, ~, -), with no segment of only . or .. (URL parsing removes those, so the route would be unreachable), and not the root, which would claim every unrouted single-segment GET; cacheControl defaults to private, max-age=3600 (never shared: a session with rolling or idleTimeoutMs refreshes its cookie on every response) and is refused at construction when the platform’s Headers would reject it.

Exports

ExportKindPurpose
LocalizationPluginfunctionPlugin factory — registers the localizer, the resolution middleware, the opt-in catalogue route and a localization indicator
localizerForfunctionlocalizerFor(ctx) — the localizer for ctx.request.locale; the default locale when absent, negotiated when unsupported
localeMiddlewarefunctionThe resolution middleware, for an application registering it per route group under middleware.enabled: false
MissingMessageErrorclasst() under onMissing: 'throw' for a key no catalogue defines; carries key and locale
MissingPluralCountErrorclassA plural message formatted without a finite numeric count; carries key when known
UnsupportedLocaleErrorclassforLocale(tag) for a tag outside the supported set — callers pass configuration there, never client text
IMessageSourcetype{ name?; load(): Promise<Record<string, MessageCatalogue>> }
LocaleMiddlewareOptionstypelocaleMiddleware’s options
LocalizationPluginOptionstypeThe options above

The subpath @setu-ts/localization-plugin/format exports format(message, values, locale, options?), negotiateLocale(candidates, supported, excluded?), parseAcceptLanguage(value) and the types FormatValues, FormatOptions and AcceptLanguage. Its runtime graph is confined to its own modules — a test walks deno info --json to enforce it — so it is safe in a browser bundle.

Behavior notes

  • Resolution order: query parameter, cookie, Accept-Language, tenantLocale, then the default. Every candidate is canonicalized inside a guard (a malformed tag is “no match”) and matched against supportedLocales only, exactly, then with subtags stripped right to left (de-Latn-AT → de-Latn → de). A * range selects the first supported locale no q=0 range excludes; exclusion applies to * only, and the plugin never answers 406.
  • Bounded input: Accept-Language is cut to 1024 characters before splitting, at most 16 ranges are read, and a range over 35 characters is dropped; a query or cookie candidate over 35 characters is ignored before any canonicalization.
  • Headers: Vary: Accept-Language (plus Cookie while the cookie source is on) is appended before the handler on every governed response. Content-Language is written after the handler from the FINAL ctx.request.locale, only when that is a supported tag and the response has none; a rejected next() writes none. Vary: Cookie makes most CDN caching ineffective — the documented cost of the cookie source.
  • Missing keys never fail a request by default; a partial locale falls back to the default locale’s message, formatted in the REQUEST’s locale (numbers, dates and plural rules follow the reader), which is also what a browser formatting the served catalogue does. The catalogue route serves each locale overlaid on the default, so a browser falls back the same way.
  • The formatter escapes nothing; escaping belongs to the renderer, as for IViewEngine.render. In English count: 0 selects the other plural form.
  • Shared code, not identical output across runtimes: Intl data differs between runtimes, and a Date formats in the runtime’s zone unless timeZone is set on both sides.
  • No optionalDependencies on tenancy: the tenant default is read per request, which middleware priority (40 < 45) orders. The plugin declares CAPABILITIES.LOGGER only, so a partial-catalogue warning raised during register() reaches the logger.

Agent card · TypeDoc API reference