Skip to content
loclizr
packages/i18n/loclizr.config.ts
import { defineConfig } from 'loclizr'
export default defineConfig({
locales: ['en', 'de', 'de-AT'],
sourceLocale: 'en',
scan: { include: ['../../apps/web/src/**/*.{ts,tsx}'] },
})

One build reads one catalog root and writes one tree, so a workspace picks a layout:

Layout Catalogs, config and tree check runs Scan sees
Per app in each app once per app that app’s sources
Shared package in packages/i18n once, in that package’s CI job every app in scan.include

Each app keeps its own locales/, config and tree, and its record lands in its own pull request. From the workspace root, run the loclizr each app pins:

.github/workflows/loclizr.yml
- run: pnpm -C apps/web exec loclizr check
- run: pnpm -C apps/admin exec loclizr check
Package manager Root step Runs
pnpm pnpm -C apps/web exec loclizr check the version apps/web pins
npm workspaces, yarn with node_modules npx loclizr check --cwd apps/web the hoisted version at the root

Under pnpm the root has no loclizr bin, so npx loclizr downloads the registry’s latest instead of the version the app builds with.

packages/i18n/package.json
{
"name": "@acme/i18n",
"type": "module",
"exports": {
"./messages": "./src/loclizr/messages.js",
"./groups": "./src/loclizr/groups.js"
}
}
  1. It holds the catalogs, runs loclizr build, and exports the barrel and groups.

  2. The config at the top does this. Paths then print relative to the package: ../../apps/web/src/App.tsx.

  3. One check covers every app’s strings.

The scan matches import specifiers by suffix and resolves nothing, so @/loclizr/messages binds with no alias map. Across packages that cuts both ways. Built from packages/i18n, with App.tsx calling m.nav_home():

Import in apps/web/src/App.tsx Bound usage for nav.home
'@acme/i18n/messages' no [], and LZ5004 fires
'../../../packages/i18n/src/loclizr/messages' yes ../../apps/web/src/App.tsx, scope App
'@acme/i18n/loclizr/messages' yes, by suffix same

The unbound form loses usage and warns on every build:

warn LZ5004 scan-found-nothing
The scan read 1 file and found no import of the generated messages.
fix check scan.include in loclizr.config.ts. A usage site is found through an import such as import * as m from './loclizr/messages'

To bind a package import, add "./loclizr/messages": "./src/loclizr/messages.js" to exports and import it as row three does.

An app with its own tree that imports the shared package has two barrels in one program.

Layer Same locale lists Different lists
Types Both augment LocaleRegistry by default; they merge. TS2717 with skipLibCheck off; silent under Vite’s default skipLibCheck: true, first tree’s AppLocale wins.
Runtime Each registers locales and cookie name on import; first wins. Outside production: a second generated directory registered [...]; the first registration keeps getLocale().

Either way, getLocale(), setLocale() and useLocale() are typed by the first tree’s list; augmentLocale: false on the second makes that explicit.

What works: import the app’s tree first, augmentLocale on, and give the shared package the same locales, sourceLocale and cookie. Its registration is a no-op; one locale resolves.

For a published package, set augmentLocale: false. A library cannot match locale lists it never sees, and a consumer with its own tree would get TS2717 inside node_modules once skipLibCheck is off, tsc’s default. A tree packed from dist also needs its generated .gitignore deleted: left in place, npm pack ships the directory empty. Publishing a library has the full build.

A package built with declaration: true that infers an exported type from loclizr/react, loclizr/server or loclizr/compiler needs TypeScript 5.4 or later. On 5.0 to 5.3 the emit fails with TS2742; annotate the exported function’s return type.

  • A second catalog root: catalogs is one pattern.
  • Merging another package’s catalogs into an app’s tree. A shared string lives in the shared package, or in both apps.
  • Anything cross-package but scan.include: catalogs, record and tree stay beside the config.