Reference
Generated code
The generated tree, one message of each kind, and what tree-shakes.
Built from the example app’s catalogs with groups: { errors: 'errors' }, quoted as written.
The one fallback block names its own catalog.
The tree
Section titled “The tree”Directorysrc/loclizr/
- .gitignore self-ignoring, written once, never compared
- messages.js the barrel
- messages.d.ts
- groups.js the typed lookup tier, not re-exported by the barrel
- groups.d.ts
Directorymessages/
- _locale.js locale list, source locale, cookie name, the resolver
- _locale.d.ts
- _formats.js hoisted Intl option objects
- _formats.d.ts
- app.js one module per top-level key segment
- app.d.ts
- cart.js
- cart.d.ts
- errors.js
- errors.d.ts
- nav.js
- nav.d.ts
- order.js
- order.d.ts
- terms.js
- terms.d.ts
.jsplus a printed.d.ts, never.ts: immune to the host tsconfig (jsx, aliases,strict), noallowJs, outside the app’s typecheck, notypescriptneeded. A library therefore ships the tree itself, sincetscnever copies it intooutDir: see Publishing a library.- Relative imports carry
.jsfor Node during a server render. - Dotless keys land in
_root.
The header line
Section titled “The header line”// @generated by loclizr abi=1. Do not edit; run `loclizr build`.Every file but .gitignore starts with this prune token. build deletes headered files under
outDir it did not emit; a file without the header is never touched and raises
LZ1021 outdir-foreign-file. Unchanged files are not rewritten.
Every .js follows it with // @ts-nocheck on line 2, so a checkJs project (svelte-check,
astro check) skips generated code. The .d.ts files stay checked.
The .gitignore
Section titled “The .gitignore”*!.gitignore- Keeps the tree out of git without touching the root
.gitignore; the pull request carries the record, not generated JavaScript. - Written only when the build creates
outDir; exempt from write-if-changed andLZ5002. - To commit the tree, delete it once; it stays deleted.
LZ5002 output-stalethen checks those files, since the rule fires only for files on disk.
The barrel
Section titled “The barrel”// @generated by loclizr abi=1. Do not edit; run `loclizr build`.// @ts-nocheckexport { getLocale, setLocale, subscribe } from 'loclizr'export { cookie, locales, sourceLocale } from './messages/_locale.js'export * from './messages/app.js'export * from './messages/cart.js'export * from './messages/errors.js'export * from './messages/nav.js'export * from './messages/order.js'export * from './messages/terms.js'// @generated by loclizr abi=1. Do not edit; run `loclizr build`.import type { SetLocaleOptions } from 'loclizr'
export type AppLocale = 'de' | 'de-AT' | 'en'
declare module 'loclizr' { interface LocaleRegistry { locale: AppLocale }}
export declare function getLocale(): AppLocaleexport declare function setLocale(locale: AppLocale, options?: SetLocaleOptions): voidexport declare function subscribe(listener: () => void): () => voidexport { cookie, locales, sourceLocale } from './messages/_locale.js'export * from './messages/app.js'export * from './messages/cart.js'export * from './messages/errors.js'export * from './messages/nav.js'export * from './messages/order.js'export * from './messages/terms.js'- A sibling file, not
messages/index.js, so./loclizr/messages(bundlers) and./loclizr/messages.js(Node ESM) both resolve unambiguously. declare modulemakes the package’s owngetLocale()anduseLocale()returnAppLocale;augmentLocale: falsedrops only that block.groups.jsis not re-exported: importing it is the explicit choice to give up its tree-shaking.
The two underscore modules
Section titled “The two underscore modules”// @generated by loclizr abi=1. Do not edit; run `loclizr build`.// @ts-nocheckimport { $configure1 } from 'loclizr'
export const locales = /*#__PURE__*/ Object.freeze(['de', 'de-AT', 'en'])export const sourceLocale = 'en'export const cookie = 'locale'export const $l = $configure1({ locales, sourceLocale, cookie })// @generated by loclizr abi=1. Do not edit; run `loclizr build`.import type { LocaleResolver } from 'loclizr'export declare const locales: readonly ['de', 'de-AT', 'en']export declare const sourceLocale: 'en'export declare const cookie: 'locale'export declare const $l: LocaleResolver$l is the resolver every message calls. $configure1 is deliberately not PURE, since it
registers the locale list with the store; the frozen constants are, so an unread array can go.
// @generated by loclizr abi=1. Do not edit; run `loclizr build`.// @ts-nocheckexport const $f44136fa355b3678a = /*#__PURE__*/ Object.freeze({})export const $f56d532f63ea89042 = /*#__PURE__*/ Object.freeze({ currency: 'USD', style: 'currency' })export const $f67d978756bf2d048 = /*#__PURE__*/ Object.freeze({ dateStyle: 'medium' })// @generated by loclizr abi=1. Do not edit; run `loclizr build`.import type { IntlOptions } from 'loclizr'export declare const $f44136fa355b3678a: IntlOptionsexport declare const $f56d532f63ea89042: IntlOptionsexport declare const $f67d978756bf2d048: IntlOptions- One constant per distinct option object, so namespaces share one cached
Intlformatter (the runtime caches by object identity). - Named by a 64-bit hash of the options’ canonical JSON, so a new format renumbers nothing.
- The empty object serves a bare
{count, number}and a plural’s#.
One message of each kind
Section titled “One message of each kind”Every message is f(args, opts?), its source text the doc comment. args is exactly the ICU
argument set, so an argument named locale still compiles: the locale override is opts.
nav.home takes no arguments.
export function nav_home(args, opts) { switch ($l(opts)) { case 'de': case 'de-AT': return `Startseite` default: return `Home` }}/** en: "Home" */export declare function nav_home(args?: EmptyArgs, opts?: MessageOptions): stringde-AT.jsonlacksnav.home, sode-ATshares the German arm: fallback resolved at build time.EmptyArgsacceptsm.nav_home(),m.nav_home({}, { locale: 'de' })andm.nav_home(undefined, { locale: 'de' });m.nav_home({ x: 1 })is a type error.
cart.greeting is Hi {name}, your cart is ready.
export function cart_greeting(args, opts) { switch ($l(opts)) { case 'de': return `Hallo ${args.name}, dein Warenkorb ist fertig` case 'de-AT': return `Servus ${args.name}, dein Einkaufswagerl ist fertig` default: return `Hi ${args.name}, your cart is ready` }}/** en: "Hi {name}, your cart is ready" */export declare function cart_greeting(args: { name: string | number }, opts?: MessageOptions): stringA bare {name} is string | number; ICU stringifies either. The de-AT arm was converted in
memory from i18next’s Servus {{name}}, ....
cart.items is {count, plural, =0 {Your cart is empty} one {# item in your cart} other {# items in your cart}}.
export function cart_items(args, opts) { const l = $l(opts) const n0 = args.count switch (l) { case 'de': if (n0 === 0) return `Dein Warenkorb ist leer` return `${$number1(l, n0, $f44136fa355b3678a)} Artikel in deinem Warenkorb` case 'de-AT': if (n0 === 0) return `Dein Einkaufswagerl ist leer` return `${args.count} Artikel im Einkaufswagerl` default: if (n0 === 0) return `Your cart is empty` switch ($plural1('en', n0, false)) { case 'one': return `${$number1(l, n0, $f44136fa355b3678a)} item in your cart` default: return `${$number1(l, n0, $f44136fa355b3678a)} items in your cart` } }}/** en: "{count, plural, =0 {Your cart is empty} one {# item in your cart} other {# items in your cart}}" */export declare function cart_items(args: { count: number }, opts?: MessageOptions): string- Exact branches go first.
$plural1runs only when a keyword branch differs fromother; the Germanoneandothermatch, so that arm skips it. - The
de-ATarm came from i18next, which prints placeholders unformatted, so${args.count}stays raw instead of1000becoming1.000on import day.
| Step | Locale used |
|---|---|
Plural category ($plural1) |
the arm’s own language, as a literal ('en') |
Number formatting ($number1) |
the requesting l: m.cart_items({ count: 1234 }) renders 1,234 items in your cart |
The literal matters on a fallback arm. A second tree, locales: ['en', 'fr', 'ja'], has
{count, plural, one {# active session} other {# active sessions}} in en.json only. An arm
identical to the source arm gets no case of its own, so fr and ja land in default:; their
only trace is the locales list in _locale.js.
export function sessions(args, opts) { const l = $l(opts) const n0 = args.count switch (l) { default: switch ($plural1('en', n0, false)) { case 'one': return `${$number1(l, n0, $f44136fa355b3678a)} active session` default: return `${$number1(l, n0, $f44136fa355b3678a)} active sessions` } }}| Call | Renders |
|---|---|
m.sessions({ count: 0 }, { locale: 'fr' }) |
0 active sessions |
m.sessions({ count: 1 }, { locale: 'ja' }) |
1 active session |
m.sessions({ count: 1234 }, { locale: 'fr' }) |
1 234 active sessions |
Choosing the category with l would print 0 active session (French one) and
1 active sessions (Japanese other). The last row’s grouping is French, from l.
order.status is {state, select, packing {Being packed} shipped {On its way} delivered {Delivered} other {Processing}}.
export function order_status(args, opts) { switch ($l(opts)) { case 'de': case 'de-AT': switch (args.state) { case 'delivered': return `Zugestellt` case 'packing': return `Wird verpackt` case 'shipped': return `Unterwegs` default: return `In Bearbeitung` } default: switch (args.state) { case 'delivered': return `Delivered` case 'packing': return `Being packed` case 'shipped': return `On its way` default: return `Processing` } }}/** en: "{state, select, packing {Being packed} shipped {On its way} delivered {Delivered} other {Processing}}" */export declare function order_status( args: { state: 'delivered' | 'packing' | 'shipped' }, opts?: MessageOptions,): stringThe argument is the union of the source’s named branches, other excluded, so a call site can
only pass a value some branch names. The example derives its Delivery type from it: Parameters<typeof m.order_status>[0]['state'].
cart.total is Total: {amount, number, ::currency/USD}.
export function cart_total(args, opts) { const l = $l(opts) switch (l) { case 'de': case 'de-AT': return `Summe: ${$number1(l, args.amount, $f56d532f63ea89042)}` default: return `Total: ${$number1(l, args.amount, $f56d532f63ea89042)}` }}/** en: "Total: {amount, number, ::currency/USD}" */export declare function cart_total(args: { amount: number }, opts?: MessageOptions): stringThe skeleton resolved at build time to $f56d532f63ea89042.
m.cart_total({ amount: 42.5 }, { locale: 'de-AT' }) renders Summe: $ 42,50.
cart.updated is Updated {at, date, medium}.
export function cart_updated(args, opts) { const l = $l(opts) switch (l) { case 'de': case 'de-AT': return `Aktualisiert ${$dateTime1(l, args.at, $f67d978756bf2d048)}` default: return `Updated ${$dateTime1(l, args.at, $f67d978756bf2d048)}` }}/** en: "Updated {at, date, medium}" */export declare function cart_updated(args: { at: Date | number }, opts?: MessageOptions): stringTakes a Date or epoch number. formats.timeZone, when set, is merged into the option object.
terms.accept is Read our <link>terms</link> before you continue.
export function terms_accept(args, opts) { switch ($l(opts)) { case 'de': case 'de-AT': return ['Lies unsere ', args.link(['AGB']), ', bevor du fortfährst.'] default: return ['Read our ', args.link(['terms']), ' before you continue.'] }}/** en: "Read our <link>terms</link> before you continue." */export declare function terms_accept<T>( args: { link: (chunks: readonly (string | T)[]) => T }, opts?: MessageOptions,): readonly (string | T)[]Returns an array of parts. Each tag is a required handler from chunks to one part type T.
TypeScript reads T from where the result goes, such as the of prop of Parts. A call with
nowhere to read it from, such as a const hoisted out of JSX, infers unknown, so pass it:
m.terms_accept<ReactNode>({ link: (chunks) => <a href="/terms">{chunks}</a> }), or annotate the
chunks as (chunks: readonly ReactNode[]) => ....
| Where | Call | Result |
|---|---|---|
| React | <Parts of={m.terms_accept({ link: (chunks) => <a href="/terms">{chunks}</a> })} /> |
rendered through Parts from loclizr/react |
| Plain Node | m.terms_accept<{ a: unknown }>({ link: (chunks) => ({ a: chunks }) }) |
["Read our ",{"a":["terms"]}," before you continue."] |
Once a translation nests one tag inside another, {chunks} hands React an array holding an
element, and React warns about keys in development; the output is still correct. Spreading the
chunks as children, createElement('a', { href: '/terms' }, ...chunks), does not warn.
groups: { errors: 'errors' } collects errors.forbidden, errors.not_found and
errors.rate_limited.
// @generated by loclizr abi=1. Do not edit; run `loclizr build`.// @ts-nocheckimport { errors_forbidden, errors_not_found, errors_rate_limited } from './messages/errors.js'
export const errors = /*#__PURE__*/ Object.freeze({ __proto__: null, forbidden: errors_forbidden, not_found: errors_not_found, rate_limited: errors_rate_limited,})// @generated by loclizr abi=1. Do not edit; run `loclizr build`.import type { EmptyArgs, MessageOptions } from 'loclizr'
export type ErrorsKey = 'forbidden' | 'not_found' | 'rate_limited'export interface ErrorsArgs { forbidden: EmptyArgs not_found: EmptyArgs rate_limited: { seconds: number }}export declare const errors: Readonly<{ [K in ErrorsKey]: (args: ErrorsArgs[K], opts?: MessageOptions) => string}>- Member property: the key minus the prefix and dot, mangled.
errors.rate_limited({ seconds: 30 })is checked exactly.errors[code]takes the intersection of every member’s arguments, andargsis required, soerrors[code]()cannot compile.- That intersection is the cost
LZ4006 group-args-heterogeneousnames; the example turns it off to show the call site. See Dynamic keys. __proto__: nullkeeps a member namedconstructorfrom reachingObject.prototype.
What can throw at run time
Section titled “What can throw at run time”Types are the only guard; validate API values at the boundary. Run under Node, locale en:
| Call | Result |
|---|---|
m.cart_greeting({}) |
renders Hi undefined, your cart is ready |
m.cart_items({ count: NaN }) |
renders NaN items in your cart |
m.cart_total({}) |
renders Total: $NaN |
m.order_status({ state: 'nope' }) |
renders Processing, the other branch |
m.cart_updated({ at: new Date('nope') }) |
throws RangeError: Invalid time value from Intl.DateTimeFormat |
m.terms_accept({}) |
throws TypeError: args.link is not a function |
Only the two external calls throw: Intl for a date, your handler for markup.
How a call resolves its locale
Section titled “How a call resolves its locale”matchLocale(opts?.locale ?? getRawLocale(), locales, sourceLocale)$l(opts) runs this against its own tree’s list, so two trees with different locale sets share
one store.
getRawLocale(): the request scope, else the tagsetLocalestored, else (with adocument) the cookie then<html lang>, read once. When all of those miss on a server whereloclizr/serveris loaded, it warns once outside production that the call escaped its scope.- Match: exact tag, then subtags dropped from the right, then the source locale.
de-CHrendersde,sprendersen.
Re-rendering on a switch
Section titled “Re-rendering on a switch”A message call subscribes to nothing. Key the root on useLocale() to remount, or pass
{ locale: useLocale() } per call to re-render in place, keeping state; a memoizing compiler then
cannot cache the string across a switch. Language switching has both.
What tree-shakes and what does not
Section titled “What tree-shakes and what does not”| Module | Behaviour |
|---|---|
| Message modules | Shake per function. Three calls ship three functions, each with every locale’s arms. |
_formats.js |
Pure; an unused format drops with its last message. |
_locale.js |
Always retained, by design: $l is load-bearing. |
groups.js |
Does not shake: importing errors keeps all three members. Only apps importing ./loclizr/groups pay. |
./loclizr/messages/cart.js |
Deep import: the escape hatch where a bundler shakes nothing, and the hand split for React Native (Metro does not shake). |
Identifiers
Section titled “Identifiers”nav.home becomes nav_home: flat, since a namespace object would defeat tree-shaking.
Mangling, in order:
- Normalize the key to NFC.
- Replace every character that is not
ID_Continue,$or_with_. - Prefix
$if the result does not start withID_Start,_or$. - Prefix
$if it is a reserved word,defaultorthen(athenexport breaksawait import()).
An identifiers config entry replaces the result, then passes steps 2 to 4. Filenames mangle the
same way, so ../x.y cannot escape outDir.
Collisions and reserved names
Section titled “Collisions and reserved names”| Case | Result |
|---|---|
| Two keys, one identifier | LZ4001 identifier-collision, fatal, no counter suffix |
| A group id equal to a grouped message’s id | LZ4001 identifier-collision |
__proto__, constructor, prototype, a barrel export (cookie, locales, sourceLocale, getLocale, setLocale, subscribe), a key or override starting with $ and a lowercase letter (step 4’s $then is fine), a top-level key segment that would mangle to _locale, _formats or _root, or Object as a group id or grouped message id |
LZ4002 identifier-reserved |
*/ in source text |
escaped in the doc comment |
Determinism
Section titled “Determinism”Emit sorts everything itself, by code point:
| What | Order |
|---|---|
| messages | by key |
| namespaces | by mangled filename |
| hoisted formats | by name |
| group members | by member property |
| groups | by id |
| locale arms | by tag, the source locale always last under default: |
Every build, in memory, emits a second time over a reversed program and byte-compares. A difference is
LZ4005 nondeterministic-output, fatal, and a bug to report. Inputs, and nothing else:
| Input | What it touches |
|---|---|
| the catalogs, the meta sidecar and the config | everything |
| the scanned sources | the record’s usage entries only |
the build host’s ICU and CLDR data, through Intl.PluralRules |
which plural categories a locale has: the =0 or zero arm a target locale gets from an i18next _zero key, and the LZ3007 and LZ3013 category checks |
The four $ imports are the private ABI, versioned by the trailing digit: a tree from before a
breaking change fails at link time because the named export is gone. Apps never call them; see Runtime API.