Skip to content
loclizr

Build pass: catalogs and the meta sidecar, classified per file, parse into one lowered tree per message feeding emit, check and record; the usage scan adds app source to the record. Outputs: generated tree, diagnostics with exit code, committed context record.

  1. Read. loclizr build globs locales/{locale}.json, flattens each file to dotted keys and classifies each file’s format (below). No catalog is written back.
  2. Parse once. @formatjs/icu-messageformat-parser parses every value once into a small tree per locale: text, arguments with inferred types, plural and select branches, markup tags.
  3. Fall back. de-AT falls back through de to en, giving each message an origin per locale: translated, inherited, or fell back to source.
  4. 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.

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.

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.

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.

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.

getLocale() reads, in order:

  1. the server request scope, if one is active
  2. the tag setLocale stored
  3. on the client, the cookie, then <html lang>
  4. 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.

Nothing transforms your source; the cost is a build step and generated files:

  • src/loclizr/ must exist before anything imports it, hence the predev, prebuild and pretypecheck scripts.
  • It is gitignored by default, so a fresh clone runs build first.
  • No --watch: loclizr build --no-fail in predev exits 0 once output is written, so an untranslated key never stops vite dev.
vite.config.ts
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 catalogs config points elsewhere than locales/
  • 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.