Start here
How it works
One pass over the catalogs, one lowered tree, three consumers.
One build pass
Section titled “One build pass”- Read.
loclizr buildglobslocales/{locale}.json, flattens each file to dotted keys and classifies each file’s format (below). No catalog is written back. - Parse once.
@formatjs/icu-messageformat-parserparses every value once into a small tree per locale: text, arguments with inferred types, plural and select branches, markup tags. - Fall back.
de-ATfalls back throughdetoen, giving each message an origin per locale: translated, inherited, or fell back to source. - Emit, check, record. All three read that tree, never the JSON, so a diagnostic cannot disagree with the emitted code.
| File | Read as |
|---|---|
Values carry {{var}}, or keys carry a CLDR plural suffix |
i18next, rewritten to ICU in memory |
| Anything else | ICU, the native format |
The example’s de-AT.json is i18next beside two ICU files, no config needed. The source
locale’s en.meta.json holds descriptions and placeholder notes, so a translation tool never ships
them as copy.
Three outputs
Section titled “Three outputs”Typed functions
Section titled “Typed functions”One plain ESM module plus printed .d.ts per top-level key segment, under
src/loclizr/messages/. Each function takes exactly its ICU argument set and switches over the
declared locales, the source locale under default.
The generated .gitignore ignores the whole directory; a header on every file lets the next build
prune only what it wrote.
Diagnostics
Section titled “Diagnostics”| Error | Output |
|---|---|
| Fatal (invalid config, identifier collision) | Nothing emitted |
| Non-fatal (missing translation) | Emitted, gaps filled from the fallback chain, exit 1 |
Twelve missing German strings still write messages/cart.js and print twelve LZ3001 lines,
rather than burying them under missing-module typecheck errors.
The context record
Section titled “The context record”locales/loclizr.context.json is a pure function of the tree: no timestamps, tool version or line
numbers, never its own previous value. Context record lists its
fields.
check compares the committed copy against what it would write, minus usage and translations:
a moved component or a translator’s batch cannot fail the gate; an edited string does.
Runtime
Section titled “Runtime”Generated code imports four helpers from loclizr: a locale resolver and Intl.PluralRules,
Intl.NumberFormat and Intl.DateTimeFormat wrappers, formatters cached by options identity. No
ICU parser reaches the browser; what ships is a function with a switch.
| Where | What loads | Size, gzipped and unminified |
|---|---|---|
| Browser | The generated tree and the loclizr entry (locale store, cookie reader, resolver, three Intl wrappers), which imports only its store chunk and no dependencies. |
Example tree (26 messages, 3 locales, 10 modules): 8.8 kB raw, 1.9 kB gzipped. Store in dist: 5.3 kB raw, 1.9 kB gzipped. |
| Server | Adds loclizr/server, which imports node:async_hooks for AsyncLocalStorage. |
|
| Build and CI | loclizr/compiler and the CLI, plus four production dependencies: @formatjs/icu-messageformat-parser, jiti, picocolors, tinyglobby. |
Security goes further.
Resolving the locale
Section titled “Resolving the locale”getLocale() reads, in order:
- the server request scope, if one is active
- the tag
setLocalestored - on the client, the cookie, then
<html lang> - the source locale
A call’s second argument, { locale }, overrides all of it. On the server, runWithLocale
scopes it per request through AsyncLocalStorage; no mutable global exists.
No bundler plugin, and its cost
Section titled “No bundler plugin, and its cost”Nothing transforms your source; the cost is a build step and generated files:
src/loclizr/must exist before anything imports it, hence thepredev,prebuildandpretypecheckscripts.- It is gitignored by default, so a fresh clone runs
buildfirst. - No
--watch:loclizr build --no-failinpredevexits 0 once output is written, so an untranslated key never stopsvite dev.
The example’s Vite plugin
Section titled “The example’s Vite plugin”import react from '@vitejs/plugin-react'import { defineConfig, normalizePath, type Plugin } from 'vite'
function loclizrCatalogs(): Plugin { return { name: 'loclizr-catalogs', configureServer(server) { const root = server.config.root const catalogs = normalizePath(`${root}/locales`) const record = `${catalogs}/loclizr.context.json` let pending = Promise.resolve() const rebuild = (changed: string): void => { const file = normalizePath(changed) if (!file.startsWith(`${catalogs}/`) || file === record) return pending = pending .then(async () => { const { build } = await import('loclizr/compiler') const { summary } = await build({ cwd: root, failOnError: false }) const counts = `${summary.messages} messages, ${summary.errors} errors, ${summary.warnings} warnings` server.config.logger.info(`loclizr rebuilt ${file.slice(root.length + 1)}: ${counts}`) }) .catch((error: unknown) => { server.config.logger.error(`loclizr rebuild failed: ${String(error)}`) }) } server.watcher.add(catalogs) server.watcher.on('add', rebuild) server.watcher.on('change', rebuild) server.watcher.on('unlink', rebuild) }, }}
export default defineConfig({ plugins: [react(), loclizrCatalogs()],})Inline in the example’s vite.config.ts, not a package: it watches locales/, calls build from
loclizr/compiler on change, and HMR picks up the rewritten src/loclizr/**. No app source is
touched.
When copying, check the two marked lines:
- the watched directory, if your
catalogsconfig points elsewhere thanlocales/ failOnError: false, which keeps the dev server up through an untranslated key, like--no-fail
Without a plugin, run nodemon -w locales -x 'loclizr build --no-fail' beside the dev server.