Skip to content
loclizr
npx loclizr --help
loclizr <command> [options]
Commands
init write loclizr.config.ts and a seed catalog, then print the
package.json scripts and the CI step
build read the catalogs, check them, write the generated tree and the
context record
check everything build does with no writes, plus the output and record
gates
Options
--cwd <dir> directory to run in, default the current one
--config <path> config file to load, or the one init writes
--reporter human|json diagnostic format, default human
--max-warnings <n> exit 1 above this many warnings, --max-warnings=-1
for no cap, which is also the default
--quiet print errors only, plus a summary line when warnings
were dropped
--no-fail build only: report everything, exit 0 once output
was written
-v, --version print the installed loclizr version
-h, --help print this

Run it as npx loclizr or from a package.json script. One help text serves every command (loclizr build --help prints it too); loclizr --version (or -v) prints the installed version.

Samples are runs against examples/vite-react (en.json, en.meta.json, de.json, a sparse de-AT.json), whose config sets groups: { errors: 'errors' } and turns group-args-heterogeneous off (hence no LZ4006). Error samples name the edit behind them.

npx loclizr init, in an empty directory
wrote loclizr.config.ts
wrote locales/en.json
Install loclizr, which loclizr.config.ts and the generated code import:
npm i loclizr
Add these scripts to package.json:
"predev": "loclizr build --no-fail",
"prebuild": "loclizr build",
"pretypecheck": "loclizr build"
Add this step to CI, before any step that runs loclizr build:
# loclizr check compares the committed context record against this tree.
# loclizr build would rewrite that record in the CI workspace and pass.
- run: npx loclizr check
  • Create-only: an existing file is kept, and the output says so. package.json is never edited.
  • Takes --cwd and --config. --no-fail is a usage error.

init first reports catalogs on disk (found 3 locales at locales/{locale}.json: de, de-AT, en):

On disk Config gets
No source catalog, or an empty one severity: { 'ambiguous-source': 'error' }, a hard gate from the first commit
Existing catalogs the same line, commented out
A generated tree (messages.js with the header, outside node_modules, dist, build and outDir) augmentLocale: false, since two trees augmenting the registry in one program is TS2717
Another layout, such as public/locales/{locale}/{ns}.json or lang/{locale}.json that catalogs pattern; meta and record in the layout’s base directory when that is not locales (lang/{sourceLocale}.meta.json, lang/loclizr.context.json; public/locales/ for the {ns} layout); locales with the list it found when the pattern has {ns}; and no seed file, which would otherwise hide the real catalogs
Catalogs under a directory catalogs cannot spell, such as app/(marketing)/locales/ the default pattern, the gate commented out and no seed file; the output names the directory and says to move it
loclizr.config.ts
import { defineConfig } from 'loclizr'
export default defineConfig({
sourceLocale: 'en',
catalogs: 'locales/{locale}.json',
outDir: 'src/loclizr',
severity: { 'ambiguous-source': 'error' },
})

The seed catalog writes its plural as ICU ({count, plural, one {...} other {...}}), not _one / _other, so the default catalogFormat: 'auto' keeps reading it as ICU beside an imported i18next file. The config never sets catalogFormat.

npx loclizr build, first run, with no src/loclizr and no record yet
wrote src/loclizr (21 files) and locales/loclizr.context.json
commit locales/loclizr.context.json; `loclizr check` compares it
26 messages, 3 locales (source en), 0 errors, 0 warnings

The 21st file is the self-ignoring .gitignore, written only when outDir does not exist at the start of the run. The example tracks that file and its committed record already matches, so a clone’s first build writes 20 files and leaves the record alone, with no commit line.

Runs every rule, scans sources for usage, writes the tree under outDir and the record at record: locales/loclizr.context.json by default whatever catalogs is. init points it beside catalogs that live elsewhere.

  • Write-if-changed. An unchanged rerun prints no wrote line. A run that a fatal diagnostic blocked prints nothing generated: 1 fatal (LZ4002) in its place.
  • Prune. Header-carrying files under outDir this emit did not produce are deleted, so a renamed top-level key leaves no messages/old.js. Files without the header are kept and raise LZ1021 outdir-foreign-file.
  • Non-fatal errors still emit. A missing German string still writes messages.js from the fallback chain, prints LZ3001 and exits 1.
  • Fatal diagnostics write nothing: identifier collision, no catalogs, invalid config. A config that cannot run (LZ1001, LZ1007) exits 2 with no summary. Every other fatal run that stops before the write step exits 1, names the fatal codes in a nothing generated line, and prints no fell back to source text line, since no tree was rendered.
  • LZ5001 output-unwritable fails at the write step. The tree was rendered, so the run keeps its summary and its fell back to source text line, prints no nothing generated line, and exits 2.
npx loclizr build, after adding a top-level subscribe key to en.json
error LZ3001 missing-translation locales/de-AT.json de-AT subscribe
The de-AT catalog has no value for this key, so this message renders en text.
fix add "subscribe" to locales/de-AT.json
error LZ3001 missing-translation locales/de.json de subscribe
The de catalog has no value for this key, so this message renders en text.
fix add "subscribe" to locales/de.json
error LZ4002 identifier-reserved locales/en.json:2:17 subscribe
The key "subscribe" produces the reserved identifier "subscribe".
fix The generated barrel already exports that name, and a star export loses to it silently. Map the key in loclizr.config.ts: identifiers: { 'subscribe': 'subscribeMessage' }
nothing generated: 1 fatal (LZ4002)
27 messages, 3 locales (source en), 3 errors, 0 warnings

The nothing generated line prints under --quiet too, and when the rule was turned down to warn or off: the rule still blocks the tree.

The CI command: build with no writes, plus two gates. Here cart.greeting changed in en.json after the last build:

npx loclizr check, after editing en.json
error LZ5002 output-stale src/loclizr/messages/cart.d.ts
`src/loclizr/messages/cart.d.ts` differs from what this build would write.
fix the generated tree on disk is stale; run `loclizr build`.
error LZ5002 output-stale src/loclizr/messages/cart.js
`src/loclizr/messages/cart.js` differs from what this build would write.
fix the generated tree on disk is stale; run `loclizr build`.
error LZ5003 record-stale locales/loclizr.context.json
`locales/loclizr.context.json` no longer matches the catalogs: the contract this build derived differs from the committed record.
fix run `loclizr build` and commit the record with the string change.
26 messages, 3 locales (source en), 3 errors, 0 warnings
Gate Fires when
LZ5002 output-stale a generated file on disk differs from this build’s output, or is a header-carrying orphan; a gitignored outDir absent in CI raises nothing
LZ5003 record-stale the committed record differs from the one these catalogs produce, or was never committed

--no-fail is refused:

loclizr: --no-fail is not accepted by `loclizr check`, which is the gate

Global, after the command. --max-warnings 0 and --max-warnings=0 are equivalent.

Option Default Notes
--cwd <dir> current directory Project root: config lookup and the base of every path. Missing or not a directory: usage error.
--config <path> first of loclizr.config.ts, .mts, .js, .mjs; none means defaults Relative to --cwd. Names no file: LZ1001 config-invalid, exit 2.
--reporter human|json human Anything else: usage error.
--max-warnings <n> no cap (also any negative n) Exit 1 above n warnings. Non-integer: usage error.
--quiet off Errors only; the counts line stays when warnings were hidden, and a nothing generated line always stays. No effect on JSON.
--no-fail off build only; below.
-v, --version The installed version, exit 0, with or without a command.
-h, --help Usage block, exit 0, with or without a command.

Colour only when stdout is a TTY and NO_COLOR is unset.

The dev loop’s flag. Only the exit code changes; diagnostics, the record rewrite and LZ5007 stay. Both runs: cart.greeting deleted from de.json.

error LZ3001 missing-translation locales/de.json de cart.greeting
The de catalog has no value for this key, so this message renders en text.
fix add "cart.greeting" to locales/de.json
wrote src/loclizr (1 file) and locales/loclizr.context.json
commit locales/loclizr.context.json; `loclizr check` compares it
26 messages, 3 locales (source en), 1 error, 0 warnings
fell back to source text: de 1

Exit code 1.

Run Exit
Reached the write step, even with zero bytes changed 0 instead of 1
Wrote no output (identifier collision, missing source catalog) still 1: vite dev would fail on the missing module anyway
Could not run still 2

Only predev carries it, never the gate. A 2000-key, 10-locale catalog builds in about half a second on a laptop, so each loop is a full rebuild.

Code Meaning
0 Clean, or warnings at or under --max-warnings. Also any build --no-fail that reached the write step.
1 At least one error, warnings over --max-warnings, or a fatal diagnostic that blocked the tree at any severity.
2 The tool could not run.

Exit 2 has exactly these causes:

  • LZ1001 config-invalid, LZ1007 outdir-unsafe, LZ5001 output-unwritable, the three rules severity cannot re-level.
  • A usage error: unknown command or option, --no-fail on check or init, a bad --reporter, a non-integer --max-warnings, a missing --cwd.
  • init failing to write one of its two files.
  • An unexpected throw, printed as loclizr: <message> on stderr.
exit 2 from a config that names an un-relevelable rule
error LZ1001 config-invalid loclizr.config.ts
`severity` cannot re-level `config-invalid`.
fix LZ1001 decides whether the tool can run at all, so turning it down would let a broken build exit 0.

Twenty-one of the fifty-nine rules default to warn, so the no-cap default decides whether a freshly imported i18next catalog exits 0 or 1 on its first build.

Both sort diagnostics the same way: errors first, then code, file, locale, key, offset. Paths are POSIX and project-relative, so the order matches between a laptop and CI.

LZ3012, after adding an undescribed footer.cart beside app.cart and nav.cart
error LZ3012 ambiguous-source locales/en.json:5:14
3 keys share the source text "Cart" and one has no description.
A translator sees one string with no way to tell the meanings apart.
app.cart locales/en.json:5:14 "Heading over the cart panel. A section title, not a link."
footer.cart locales/en.json:41:14 no description
nav.cart locales/en.json:21:14 "Top navigation link that opens the cart page."
fix add descriptions in locales/en.meta.json:
"footer.cart": { "description": "" }
or turn the rule down in loclizr.config.ts:
severity: { 'ambiguous-source': 'warn' }
Part Content
Header Severity, code, rule, then whichever of file:line:column, locale and key apply.
Related A three-column table, when the rule has related locations.
Repeats Diagnostics that share severity, code, key, message and hint print once: the header ends with the key and the number of distinct files, such as 3 files, and one row per diagnostic gives its locale and file:line:column. A diagnostic with related locations always prints on its own. The JSON output and the counts keep one entry per diagnostic.
fix The rule’s hint, line breaks kept.
Summary One line of counts. On build, preceded by the wrote line and a commit reminder (unless LZ5007 already says so), and followed by fell back to source text: de 2, de-AT 1 when any message resolved all the way to source text. A run that a fatal diagnostic blocked prints nothing generated: 1 fatal (LZ4002) in place of the wrote line and no fallback line; with nothing analyzed, the counts name no source locale (0 messages, 0 locales, 1 error, 0 warnings).

Terminal control characters print as <U+009B>, and a newline in a key becomes one space.

npx loclizr check --reporter json, clean tree
{
"schema": 1,
"diagnostics": [],
"summary": {
"errors": 0,
"warnings": 0,
"messages": 26,
"locales": 3,
"fellBack": []
}
}

After deleting nav.home and cart.greeting from de.json, diagnostics elided:

npx loclizr check --reporter json, two German strings deleted
"summary": {
"errors": 3,
"warnings": 0,
"messages": 26,
"locales": 3,
"fellBack": [
{
"locale": "de",
"count": 2
},
{
"locale": "de-AT",
"count": 1
}
]
}
}

One two-space indented object on stdout. --quiet never thins it, so the counts always match the array. Every diagnostic carries every field, null where it does not apply:

Field Type Meaning
code string Stable code, LZ3001.
rule string Rule name, the key severity accepts.
severity "warn" or "error" After the config’s severity is applied.
fatal boolean Whether it blocked emission.
message string The body; may contain newlines.
hint string or null The fix text.
file string or null POSIX, project-relative.
locale string or null
key string or null Flat catalog key.
span object or null { line, column, offset, length }, 1-based line and column. null for a missing translation.
related array file, locale, key, span, message per entry: the human reporter’s table rows.
  • summary.fellBack: { "locale", "count" } per locale with any message resolved to source text.
  • A run halted by an invalid config reports "messages": 0 and "locales": 0.
  • DEL and C1 controls are escaped as \u007f through \u009f: a parser gets the catalog’s bytes back, a tailing terminal cannot be told to move the cursor.
  1. npm i loclizr, as a regular dependency: the generated tree imports the locale store at run time.

  2. package.json
    "predev": "loclizr build --no-fail",
    "prebuild": "loclizr build",
    "pretypecheck": "loclizr build"

    predev builds the tree before every dev run; --no-fail keeps one untranslated key from stopping the dev server. prebuild and pretypecheck take the real exit code.

  3. .github/workflows/ci.yml
    # loclizr check compares the committed context record against this tree.
    # loclizr build would rewrite that record in the CI workspace and pass.
    - run: npx loclizr check

    Put it before any step that runs build. A pipeline running only pnpm build rewrites the record and passes, letting a stale record merge.

There is no prepare: it runs on npm ci and would rewrite the record before check compared it. The cost: a fresh clone shows editor errors under ./loclizr/messages until the first dev, build or typecheck. See Continuous integration.