Skip to content
loclizr

Built from the example app’s catalogs with groups: { errors: 'errors' }, quoted as written. The one fallback block names its own catalog.

  • 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
  • .js plus a printed .d.ts, never .ts: immune to the host tsconfig (jsx, aliases, strict), no allowJs, outside the app’s typecheck, no typescript needed. A library therefore ships the tree itself, since tsc never copies it into outDir: see Publishing a library.
  • Relative imports carry .js for Node during a server render.
  • Dotless keys land in _root.
// @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.

src/loclizr/.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 and LZ5002.
  • To commit the tree, delete it once; it stays deleted. LZ5002 output-stale then checks those files, since the rule fires only for files on disk.
src/loclizr/messages.js
// @generated by loclizr abi=1. Do not edit; run `loclizr build`.
// @ts-nocheck
export { 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'
  • A sibling file, not messages/index.js, so ./loclizr/messages (bundlers) and ./loclizr/messages.js (Node ESM) both resolve unambiguously.
  • declare module makes the package’s own getLocale() and useLocale() return AppLocale; augmentLocale: false drops only that block.
  • groups.js is not re-exported: importing it is the explicit choice to give up its tree-shaking.
src/loclizr/messages/_locale.js
// @generated by loclizr abi=1. Do not edit; run `loclizr build`.
// @ts-nocheck
import { $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 })
src/loclizr/messages/_locale.d.ts
// @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.

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.

src/loclizr/messages/nav.js
export function nav_home(args, opts) {
switch ($l(opts)) {
case 'de':
case 'de-AT':
return `Startseite`
default:
return `Home`
}
}
src/loclizr/messages/nav.d.ts
/** en: "Home" */
export declare function nav_home(args?: EmptyArgs, opts?: MessageOptions): string
  • de-AT.json lacks nav.home, so de-AT shares the German arm: fallback resolved at build time.
  • EmptyArgs accepts m.nav_home(), m.nav_home({}, { locale: 'de' }) and m.nav_home(undefined, { locale: 'de' }); m.nav_home({ x: 1 }) is a type error.

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.

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 tag setLocale stored, else (with a document) the cookie then <html lang>, read once. When all of those miss on a server where loclizr/server is 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-CH renders de, sp renders en.

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.

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).

nav.home becomes nav_home: flat, since a namespace object would defeat tree-shaking. Mangling, in order:

  1. Normalize the key to NFC.
  2. Replace every character that is not ID_Continue, $ or _ with _.
  3. Prefix $ if the result does not start with ID_Start, _ or $.
  4. Prefix $ if it is a reserved word, default or then (a then export breaks await 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.

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

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.