@setu-ts/localization-plugin
Message catalogues per locale, request locale resolution, and one formatter shared by the server and the browser.
Overview
LocalizationPluginregisters anILocalizerunderCAPABILITIES.LOCALIZATIONand validates every catalogue at startup, so a missing translation failsregister()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 toctx.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:
zerois selected only where the locale’s rules produce it (Arabic, Latvian, …). In English,count: 0selectsother— write{ one: '{count} item', other: '{count} items' }, not azeroform, 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:
- the
localequery parameter (middleware.query,falsedisables it); - the
setu_localecookie (middleware.cookie,falsedisables it); Accept-Language— q-values honoured,de-ATfalling back tode, and a*range selecting the first supported locale the client did not exclude withq=0(soen;q=0, *never selectsen). When every locale is excluded the chain continues: the plugin never answers406;tenantLocale(tenant), when configured and a tenant is resolved — the tenancy middleware runs at 40, before this one at 45;- 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
cacheMiddlewarealways does — route middleware runs after every global one; - a global
cacheMiddlewaremust have a higher priority number than the locale middleware — 45 by default, or whatevermiddleware.priorityis 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 afterlocaleMiddleware; - a
replaceLocaleoverride 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.
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.
| Option | Type | Default | Behavior |
|---|---|---|---|
supportedLocales | readonly 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. |
catalogues | Record<string, MessageCatalogue> | — | Static catalogues keyed by tag, validated at register(). |
source | IMessageSource | — | Loaded once at register() (a rejection fails startup), validated identically; the health indicator reports injected[:name]. |
allowPartialCatalogues | boolean | false | Accept 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. |
timeZone | string | runtime zone | IANA 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.enabled | boolean | true | false registers no global middleware; attach localeMiddleware(...) yourself. |
middleware.priority | number | 45 | After tenant resolution (40), before logging (50). Must be an integer. |
middleware.query | string | false | 'locale' | The query parameter source; false disables it. |
middleware.cookie | string | false | 'setu_locale' | The cookie source; false disables it and drops Cookie from Vary. |
middleware.exclude | readonly PathPattern[] | six probe paths | Skipped entirely — no resolution, no headers. Default /live, /ready, /health, /metrics, /openapi.json, /docs; [] disables. |
exposeCatalogues | { basePath; cacheControl? } | off | Registers 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
| Export | Kind | Purpose |
|---|---|---|
LocalizationPlugin | function | Plugin factory — registers the localizer, the resolution middleware, the opt-in catalogue route and a localization indicator |
localizerFor | function | localizerFor(ctx) — the localizer for ctx.request.locale; the default locale when absent, negotiated when unsupported |
localeMiddleware | function | The resolution middleware, for an application registering it per route group under middleware.enabled: false |
MissingMessageError | class | t() under onMissing: 'throw' for a key no catalogue defines; carries key and locale |
MissingPluralCountError | class | A plural message formatted without a finite numeric count; carries key when known |
UnsupportedLocaleError | class | forLocale(tag) for a tag outside the supported set — callers pass configuration there, never client text |
IMessageSource | type | { name?; load(): Promise<Record<string, MessageCatalogue>> } |
LocaleMiddlewareOptions | type | localeMiddleware’s options |
LocalizationPluginOptions | type | The 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 againstsupportedLocalesonly, exactly, then with subtags stripped right to left (de-Latn-AT→de-Latn→de). A*range selects the first supported locale noq=0range excludes; exclusion applies to*only, and the plugin never answers406. - Bounded input:
Accept-Languageis 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(plusCookiewhile the cookie source is on) is appended before the handler on every governed response.Content-Languageis written after the handler from the FINALctx.request.locale, only when that is a supported tag and the response has none; a rejectednext()writes none.Vary: Cookiemakes 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 Englishcount: 0selects theotherplural form. - Shared code, not identical output across runtimes:
Intldata differs between runtimes, and aDateformats in the runtime’s zone unlesstimeZoneis set on both sides. - No
optionalDependencieson tenancy: the tenant default is read per request, which middleware priority (40 < 45) orders. The plugin declaresCAPABILITIES.LOGGERonly, so a partial-catalogue warning raised duringregister()reaches the logger.