Skip to content
loclizr

loclizr init prints three scripts and one CI step. The scripts build; CI checks.

  1. package.json
    "predev": "loclizr build --no-fail",
    "prebuild": "loclizr build",
    "pretypecheck": "loclizr build"
  2. Run check before any step that runs loclizr build.

    .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
Hook Runs Why
predev build --no-fail A tree has to exist; one untranslated key must not stop vite dev.
prebuild, pretypecheck build Fails on an error.
CI check Compares the committed record instead of rewriting it.
.github/workflows/loclizr.yml
on: [push, pull_request]
jobs:
loclizr:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
# No loclizr script runs on install; see the note above.
- run: npm ci
# The gate. Only this step is loclizr's; the rest prove the tree against the app.
- run: npx loclizr check
# prebuild regenerates the tree before the app builds.
- run: npm run build
- run: npm run typecheck
- run: npm test

The last two steps are the app’s own. They assume a typecheck script (tsc -b for the Vite template, as in the Quickstart) and a test script (Testing); drop either step the app does not have.

Transcripts come from examples/vite-react (26 messages, 3 locales), except the out-of-memory one under Memory on small runners. The two warning transcripts drop its 'group-args-heterogeneous': 'off' line from loclizr.config.ts, so that rule warns.

Changes the exit code and nothing else: every diagnostic is still printed and counted.

Run Exit
reached the write step 0 instead of 1
wrote no output still 1: the next command would fail anyway
could not run still 2

check refuses the flag. CLI shows both runs.

Everything build does, with no writes, plus two rules.

Rule Fires when
LZ5002 output-stale A generated file on disk differs from what this run would write.
LZ5003 record-stale The committed locales/loclizr.context.json differs from the one this tree produces.

To commit the generated tree, delete the self-ignoring .gitignore inside outDir. That choice decides what LZ5002 gates:

Generated tree What CI gates A translator’s pull request
ignored, the default Catalogs and record. check runs before the tree exists, so LZ5002 has nothing to compare and never fires in CI. Passes check.
committed The tree too: an edit to de.json changes messages/nav.js on disk. Fails check until someone reruns build and commits the result.

With the tree ignored, pretypecheck rebuilds a stale local tree before tsc runs instead of reporting it. A fresh clone gives LZ5002 nothing to compare, so check needs no prior build:

26 messages, 3 locales (source en), 0 errors, 0 warnings

The record is a pure function of catalogs, meta sidecar, scanned sources and config; it never reads its previous value. Editing a source string changes that message’s source and sourceHash, so check fails until build reruns and the record is committed with it:

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.

build runs the same comparison at warn, so a prebuild that quietly rewrote the record still says so:

warn LZ5007 record-rewritten locales/loclizr.context.json
`locales/loclizr.context.json` was rewritten: the committed record's contract differs from the one these catalogs produce.
fix commit the rewritten record with the string change, so the context lands in the same pull request.

The contract: keys, ids, modules, kinds, source text and hash, descriptions, argument names and types, plural and select shapes, markup tags.

Two fields are projected out of both sides first:

Field Why Tried on the example, tree absent
translations, per-locale status A translation batch is not a contract change. German string edited in de.json: no LZ5003 (with the tree on disk, LZ5002). German key deleted: no LZ5003 either, but LZ3001 missing-translation for the two locales that fall back, the right error.
usage, file plus enclosing scope Outside the gate; the next build rewrites them. Component calling m.nav_home() renamed, file moved: check exits 0.

build still rewrites the whole record on any byte change; only the gate ignores these fields.

Translation workflow follows a translation batch to the merge.

Flag Effect
--max-warnings No cap by default, so warnings alone never fail. --max-warnings 0 makes the warning below exit 1 on check; build --no-fail prints the same and still exits 0.
--quiet Errors only, plus a summary line when warnings were dropped.
--reporter json One object: diagnostics sorted by severity, code, file, locale, key and offset, summary last.
warn LZ4006 group-args-heterogeneous
The group "errors" has members with different argument sets, so every dynamic call site must pass the union: seconds.
errors.forbidden takes no arguments
errors.not_found takes no arguments
errors.rate_limited takes seconds
fix Split the group by argument shape, or give the odd members bare {x} arguments.
26 messages, 3 locales (source en), 0 errors, 1 warning

That is check --max-warnings 0, exit 1. The same run through --reporter json:

{
"schema": 1,
"diagnostics": [
{
"code": "LZ4006",
"rule": "group-args-heterogeneous",
"severity": "warn",
"fatal": false,
"message": "The group \"errors\" has members with different argument sets, so every dynamic call site must pass the union: seconds.",
"hint": "Split the group by argument shape, or give the odd members bare {x} arguments.",
"file": null,
"locale": null,
"key": null,
"span": null,
"related": [
{
"file": null,
"locale": null,
"key": "errors.forbidden",
"span": null,
"message": "takes no arguments"
},
{
"file": null,
"locale": null,
"key": "errors.not_found",
"span": null,
"message": "takes no arguments"
},
{
"file": null,
"locale": null,
"key": "errors.rate_limited",
"span": null,
"message": "takes seconds"
}
]
}
],
"summary": {
"errors": 0,
"warnings": 1,
"messages": 26,
"locales": 3,
"fellBack": []
}
}

file is always POSIX and relative to the project root, so sort order matches on every machine.

Code Meaning
0 clean, or warnings only and at or under --max-warnings
1 at least one error, or warnings over --max-warnings
2 the tool could not run: invalid usage, invalid config, unsafe outDir, unwritable output

Exit 134 is not loclizr’s: it is Node running out of heap, see Memory on small runners.

check and build need a heap that grows with messages times locales, from 64 MB at 1000 x 10 to 704 MB at 10000 x 30; Honest limits has the table. Those are the smallest heaps that passed, with no margin. Node gives a container about half its memory limit as heap, so a container needs more than twice the smallest heap to run with no flags: the 10000 x 30 catalog passes in 1.5 GB.

On a smaller one, set the heap once for the job. prebuild and pretypecheck both run loclizr build, so check, build and typecheck all need it:

.github/workflows/loclizr.yml
jobs:
loclizr:
runs-on: ubuntu-latest
env:
NODE_OPTIONS: --max-old-space-size=768
# steps as in the complete workflow above

Keep the value above the table’s smallest heap and below the container’s memory. 768 is what the 10000 x 30 catalog passed with in a 1 GB container. The flag is a ceiling, not a reservation: the kernel steps in only when the process outgrows the container, so --max-old-space-size=1024 still passes in that 1 GB container, while in a 512 MB container the same catalog ends in Killed, exit 137.

Container figures come from node:22-slim with Node 22.23 on arm64; GitHub’s ubuntu-latest runners are x64.

Without the flag, that 1 GB container stops check here (the ----- Native stack trace ----- section and the shell’s Aborted (core dumped) line left out):

<--- Last few GCs --->
[10:0xfa11d4760000] 9765 ms: Mark-Compact 502.7 (520.6) -> 498.9 (520.9) MB, pooled: 2 MB, 82.61 / 0.10 ms (average mu = 0.064, current mu = 0.053) allocation failure; scavenge might not succeed
[10:0xfa11d4760000] 9871 ms: Mark-Compact 502.8 (520.9) -> 499.0 (520.6) MB, pooled: 2 MB, 100.49 / 0.09 ms (average mu = 0.059, current mu = 0.052) allocation failure; scavenge might not succeed
<--- JS stacktrace --->
FATAL ERROR: Ineffective mark-compacts near heap limit Allocation failed - JavaScript heap out of memory

Some runs print Reached heap limit Allocation failed - JavaScript heap out of memory instead. Either way the exit code is 134: Node aborts before any handler runs, so loclizr prints no diagnostic and no summary line.