Skip to content
loclizr
Entry Holds
loclizr the locale store shared by generated code and your app
loclizr/react three bindings over the store
loclizr/server request scoping and Accept-Language negotiation
loclizr/compiler the build behind the CLI

Signatures are quoted from the published .d.ts; snippets ran against the example’s tree.

export declare function defineConfig(config: LoclizrConfig): LoclizrConfig
export declare function getLocale(): Locale
export declare function setLocale(locale: Locale, options?: SetLocaleOptions): void
export declare function subscribe(listener: LocaleListener): () => void
loclizr.config.ts
import { defineConfig } from 'loclizr'
export default defineConfig({ locales: ['en', 'de', 'de-AT'], sourceLocale: 'en' })

An identity function for typing. Every field is still re-checked at load.

import { getLocale } from 'loclizr'
import * as m from './loclizr/messages'
getLocale() // 'en'
m.nav_home() // 'Home'

Returns a registered locale, reading in order:

  1. the active server request scope
  2. the tag setLocale stored
  3. with a document, the configured cookie, then <html lang>, read once

Then it matches by exact tag and subtag truncation, falling back to the source locale.

  • A locale read from the cookie is also written to <html lang>, matched against the list the first generated tree registers. Once setLocale has run, that write is skipped.
  • m.getLocale(), typed as AppLocale, is the usual import.
  • The first generated _locale.js to load registers the list. Called before any message module loads, it returns the stored tag or 'en' and warns once outside production, naming the missing import.
  • On a server outside a request scope it returns the source locale, so a prerender gets source text, not a 500. Where loclizr/server is loaded, it warns once to wrap the whole request in runWithLocale; a message call outside a scope prints the same warning.
  • Warnings sit behind process.env.NODE_ENV. Where there is no process global and no bundler define (a plain <script type="module"> page, a Worker without Node compatibility), they are skipped, never thrown.
import { getLocale, setLocale, subscribe } from 'loclizr'
import * as m from './loclizr/messages'
const off = subscribe(() => console.log('now', getLocale()))
setLocale('de') // logs: now de
m.nav_home() // 'Startseite'
off()

Stores the tag and notifies subscribers. With a document it also sets <html lang> and the cookie (path=/, one year, SameSite=Lax).

Case Behaviour
{ persist: false } skips the cookie
file:// page or cookies blocked updates and notifies, keeps nothing past reload; warns once outside production
No DOM (React Native) updates and notifies in memory, persists nothing
Inside a request scope throws setLocale() cannot run inside a request scope. Pass { locale } per call instead.
import { subscribe } from 'loclizr'
import * as m from './loclizr/messages'
const unsubscribe = subscribe(() => document.title = m.app_cart())
unsubscribe()

Called after every setLocale; returns the remover. Listeners take no arguments, so read getLocale() inside.

Type Definition
Locale LocaleRegistry extends { locale: infer L extends string } ? L : string: your union once a generated tree is in the program, string before.
LocaleRegistry Empty interface; messages.d.ts adds locale: AppLocale.
MessageOptions { readonly locale?: Locale | undefined }, a message’s second parameter.
EmptyArgs { readonly [noArguments]?: never }, keyed by a symbol loclizr does not export, for a message without arguments: f() and f({}) compile, f({ x: 1 }) does not, and completion inside f({ }) offers nothing.
SetLocaleOptions { readonly persist?: boolean | undefined }
LocaleListener () => void
LocaleResolver (options?: MessageOptions | undefined) => string, the type of $l
LocaleSetup { locales, sourceLocale, cookie }, all required, passed to $configure1
NegotiateOptions { locales, sourceLocale, cookie? }, taken by loclizr/server
IntlOptions Readonly<Record<string, string | number | boolean>>
LoclizrConfig the config shape, see Configuration
export declare function $configure1(setup: LocaleSetup): LocaleResolver
export declare function $plural1(locale: string, value: number, ordinal: boolean): string
export declare function $number1(locale: string, value: number, options: IntlOptions): string
export declare function $dateTime1(locale: string, value: Date | number, options: IntlOptions): string

Generated code’s ABI; apps never call them.

Helper Does
$configure1 registers the locale list (first wins), returns the resolver bound to that tree’s list
$plural1 caches one Intl.PluralRules per locale and type
$number1, $dateTime1 cache formatters by options identity, hence _formats.js

The trailing digit is the version. A breaking change renames the helper; the old name stays one minor, then a stale tree fails at link time.

export declare function useLocale(): Locale
export declare function useSetLocale(): (locale: Locale, options?: SetLocaleOptions) => void
export declare function Parts(props: {
readonly of: readonly (string | ReactNode)[];
}): ReactElement
export type { Locale, SetLocaleOptions }

No 'use client' directive in v0.1; add one on your own file if needed.

import { useLocale } from 'loclizr/react'
import * as m from './loclizr/messages'
function Root() {
return <App key={useLocale()} /> // whole tree remounts on a switch
}
function Draft({ count }: { count: number }) {
const locale = useLocale()
return <p>{m.cart_items({ count }, { locale })}</p> // re-renders in place, keeps state
}

useSyncExternalStore(subscribe, getLocale, getLocale) with module-level references, so no resubscribe per render and the snapshot is a primitive string. It is the only subscription: a component calling messages without it re-renders only with its parent.

Outside production it compares <html lang> with the resolved locale, warns once when they differ by more than letter case (naming the Content-Language header withLocale sets) or when the attribute is missing, and assigns it. See React.

import { useLocale, useSetLocale } from 'loclizr/react'
import * as m from './loclizr/messages'
function Switcher() {
const locale = useLocale()
const setLocale = useSetLocale()
return (
<select value={locale} onChange={(e) => setLocale(e.target.value as typeof locale)}>
{m.locales.map((l) => <option key={l} value={l}>{l}</option>)}
</select>
)
}

Returns setLocale, so a switcher reads as a hook pair.

import { Parts } from 'loclizr/react'
import * as m from './loclizr/messages'
<Parts of={m.terms_accept({ link: (chunks) => <a href="/terms">{chunks}</a> })} />

The whole component is createElement(Fragment, null, ...props.of), so Parts itself needs no keys. Once a translation nests tags, a JSX handler logs React’s dev-only key warning; createElement('a', { href: '/terms' }, ...chunks) avoids it.

export declare function runWithLocale<T>(locale: string, fn: () => T): T
export declare function withLocale<A extends unknown[]>(
handler: (request: Request, ...rest: A) => Response | Promise<Response>,
options: NegotiateOptions,
): (request: Request, ...rest: A) => Promise<Response>
export declare function withLocale<A extends unknown[]>(
handler: (request: Request, ...rest: A) => Response | undefined | Promise<Response | undefined>,
options: NegotiateOptions,
): (request: Request, ...rest: A) => Promise<Response | undefined>
export declare function negotiate(accepted: readonly string[], options: NegotiateOptions): string
export declare function localeFromRequest(request: Request, options: NegotiateOptions): string
export declare function localeFromHeaders(headers: {
readonly cookie?: string | undefined;
readonly acceptLanguage?: string | undefined;
}, options: NegotiateOptions): string
export type { NegotiateOptions }

Negotiating functions take what they need in NegotiateOptions (usually m.locales, m.sourceLocale and m.cookie), so loclizr/server never imports the tree.

import { runWithLocale } from 'loclizr/server'
import { getLocale } from 'loclizr'
import * as m from './loclizr/messages'
runWithLocale('de-AT', () => [getLocale(), m.nav_cart()]) // ['de-AT', 'Einkaufswagerl']

Runs fn in an AsyncLocalStorage scope; every getLocale() and message call on that async path resolves to locale.

  • The handle lives in globalThis[Symbol.for('loclizr.locale')], so two module graphs share one scope (two graphs is how Next serves the wrong language under concurrency elsewhere).
  • Express, Fastify and Pages Router handlers call it after localeFromHeaders.
import { withLocale } from 'loclizr/server'
import * as m from './loclizr/messages'
const handler = withLocale(
async (request) => new Response(m.nav_cart()),
{ locales: m.locales, sourceLocale: m.sourceLocale, cookie: m.cookie },
)
const response = await handler(new Request('http://x/', { headers: { cookie: 'locale=de' } }))
await response.text() // 'Warenkorb'
response.headers.get('content-language') // 'de'
response.headers.get('vary') // 'Accept-Language, Cookie'

Wraps a Fetch handler: negotiates, runs it inside runWithLocale, sets Content-Language and adds Accept-Language and Cookie to Vary. A Response.error() passes through untouched, and so does the undefined a Bun handler returns after server.upgrade(request).

The scope covers the handler and the async work it starts; a body produced as the client reads it can fall outside it. See Bodies produced later.

negotiate(['fr', 'de-CH;q=0.8', 'en;q=0.5'], { locales: m.locales, sourceLocale: 'en', cookie: m.cookie }) // 'de'

Hand-rolled RFC 4647 lookup over Accept-Language ranges:

  1. Sort by q, then order; drop q=0 and *. A q above 1 counts as 1, and a q that is not a plain decimal counts as q=0.
  2. Match each by exact tag, then subtag truncation.
  3. First match wins; none yields sourceLocale.
const options = { locales: m.locales, sourceLocale: 'en', cookie: m.cookie }
localeFromHeaders({ cookie: 'locale=de-AT; theme=dark', acceptLanguage: 'en' }, options) // 'de-AT'
localeFromHeaders({ acceptLanguage: 'de;q=0.9, en;q=0.8' }, options) // 'de'
localeFromHeaders({}, options) // 'en'

The primitive: the cookie named by options.cookie (locale when omitted; pass m.cookie to match the config), then Accept-Language, then the source locale.

  • An unmatched cookie yields the source locale, not Accept-Language, so server and client agree across hydration.
  • Takes two optional strings, not Headers. Express passes { cookie: req.headers.cookie, acceptLanguage: req.headers['accept-language'] }.
localeFromRequest(new Request('http://x/', { headers: { 'accept-language': 'de-AT' } }), options) // 'de-AT'

localeFromHeaders over the request’s headers: one implementation, no drift.

export interface BuildOptions {
readonly cwd?: string | undefined
readonly configPath?: string | undefined
readonly emit?: boolean | undefined
readonly maxWarnings?: number | undefined
readonly failOnError?: boolean | undefined
}
export declare function build(options?: BuildOptions): Promise<BuildResult>
export declare function check(options?: BuildOptions): Promise<BuildResult>

The CLI is a thin layer over these. build writes the tree and record; check writes nothing and adds the LZ5002 and LZ5003 gates. Neither prints; reporters belong to the CLI.

Option Default Meaning
cwd process.cwd() project root
configPath discovery config file relative to cwd
emit true false writes nothing, even on build; files and record are still filled
maxWarnings no cap; any negative value is also no cap --max-warnings
failOnError true false is build --no-fail; check ignores it
import { build } from 'loclizr/compiler'
const result = await build({ cwd: root, failOnError: false })
result.summary // { errors: 0, warnings: 0, messages: 26, locales: 3, fellBack: [] }
result.exitCode // 0
result.written.length // 21 on a first run, 0 when nothing changed

The example’s Vite plugin makes this call on a catalog change. check has the same shape:

import { check } from 'loclizr/compiler'
const gate = await check({ cwd: root })
gate.exitCode // 0 on a clean, committed tree
gate.diagnostics.map((d) => d.code) // []
gate.written // []
Field Type Meaning
ok boolean no error diagnostic after re-levelling
exitCode 0 | 1 | 2 the CLI’s exit code
program Program | null config, sourceLocale, locales, messages, extras, groups, usages, diagnostics; null if the run halted before reading catalogs
diagnostics Diagnostic[] sorted, re-levelled, as the JSON reporter prints them
files EmittedFile[] every { path, contents }, relative to outDir, even with emit: false
record ContextRecord | null null with record: false or a halted run
written string[] paths written this run, relative to the project root; empty for check
summary Summary { errors, warnings, messages, locales, fellBack }
  • Diagnostic, Summary, ContextRecord, Program and EmittedFile are exported types.
  • Diagnostic matches the JSON reporter; ContextRecord is the context record.
  • program.usages keeps the full UsageSite (file, line, column, scope, snippet); the committed record keeps only file and scope.