Skip to content
loclizr

loclizr build writes the record to the record path, locales/loclizr.context.json by default whatever catalogs says; commit it. loclizr init points it beside catalogs that live elsewhere. Whoever translates next, a person, a model or a TMS, reads it.

It is a pure function of the catalogs, the meta sidecar, the scanned sources and the config, and never reads its own previous value. A fresh and an incremental regenerate agree byte for byte, and two branches editing one string cannot conflict on a sticky flag inside a generated file.

Catalogs, the meta sidecar and the usage scan feed the record step of loclizr build, which writes locales/loclizr.context.json deterministically. The file is committed, so its diff sits in the pull request beside the string change, loclizr check gates it in CI, and a translator, a model or a TMS reads it.

Three of the example app’s twenty-six messages: a plural, a select and a markup message.

locales/loclizr.context.json
{
"schema": 1,
"sourceLocale": "en",
"locales": [
"de",
"de-AT",
"en"
],
"messages": [
{
"key": "cart.items",
"id": "cart_items",
"module": "messages/cart.js",
"kind": "text",
"source": "{count, plural, =0 {Your cart is empty} one {# item in your cart} other {# items in your cart}}",
"sourceHash": "a826cf6a40d3293e",
"description": "Badge under the cart icon on every page.",
"args": [
{
"name": "count",
"type": "number",
"options": null,
"note": "Number of line items, not total quantity."
}
],
"variants": [
{
"arg": "count",
"kind": "plural",
"matches": [
"=0",
"one",
"other"
]
}
],
"markup": [],
"translations": [
{
"locale": "de",
"status": "translated",
"from": null,
"reason": null
},
{
"locale": "de-AT",
"status": "translated",
"from": null,
"reason": null
},
{
"locale": "en",
"status": "translated",
"from": null,
"reason": null
}
],
"usage": [
{
"file": "src/App.tsx",
"scope": "App"
}
]
},
{
"key": "order.status",
"id": "order_status",
"module": "messages/order.js",
"kind": "text",
"source": "{state, select, packing {Being packed} shipped {On its way} delivered {Delivered} other {Processing}}",
"sourceHash": "0277ce5c835efb7e",
"description": "Chip in the order list. Past tense.",
"args": [
{
"name": "state",
"type": "select",
"options": [
"packing",
"shipped",
"delivered"
],
"note": null
}
],
"variants": [
{
"arg": "state",
"kind": "select",
"matches": [
"packing",
"shipped",
"delivered",
"other"
]
}
],
"markup": [],
"translations": [
{
"locale": "de",
"status": "translated",
"from": null,
"reason": null
},
{
"locale": "de-AT",
"status": "inherited",
"from": "de",
"reason": null
},
{
"locale": "en",
"status": "translated",
"from": null,
"reason": null
}
],
"usage": [
{
"file": "src/App.tsx",
"scope": null
},
{
"file": "src/App.tsx",
"scope": "App"
}
]
},
{
"key": "terms.accept",
"id": "terms_accept",
"module": "messages/terms.js",
"kind": "markup",
"source": "Read our <link>terms</link> before you continue.",
"sourceHash": "b2a2946cc6836837",
"description": "Sits under the checkout button. The link opens the terms page.",
"args": [
{
"name": "link",
"type": "markup",
"options": null,
"note": null
}
],
"variants": [],
"markup": [
"link"
],
"translations": [
{
"locale": "de",
"status": "translated",
"from": null,
"reason": null
},
{
"locale": "de-AT",
"status": "inherited",
"from": "de",
"reason": null
},
{
"locale": "en",
"status": "translated",
"from": null,
"reason": null
}
],
"usage": [
{
"file": "src/App.tsx",
"scope": "App"
}
]
}
]
}
Field Type Meaning
schema 1 Record format version, the only one there is.
sourceLocale string Locale whose catalog defines the message set.
locales string[] Every declared locale, sorted by code point.
messages array One per source-catalog message, sorted by key by code point.
Field Type Meaning
key string Flat catalog key after namespace prefixing and plural folding; what every diagnostic prints.
id string Generated identifier, m.cart_items.
module string Generated module that exports it, relative to outDir.
kind "text" or "markup" Returns a string, or an array of parts.
source string Canonical ICU source text, identical for i18next and ICU catalogs with the same meaning.
sourceHash string SHA-256 of source as UTF-8 (an unpaired surrogate as its three-byte WTF-8 form, not U+FFFD), first sixteen hex characters.
description string or null From the meta sidecar.
args array Arguments, in first-appearance order.
variants array Every plural, selectordinal and select, with its branches.
markup string[] Tag names, sorted.
translations array One per declared locale, sorted.
usage array Where the scan found a reference, deduplicated, sorted by file then scope.
Field Type Meaning
name string Argument name.
type "text", "number", "date", "select" or "markup" text is a bare {name}, typed string | number at the call site. date covers date and time. markup is a tag handler.
options string[] or null For select: the named branches without other, the union the call site is typed as.
note string or null Placeholder note from the meta sidecar.
Field Type Meaning
arg string Selector argument.
kind "plural", "selectordinal" or "select" The construct.
matches string[] Plural: exact =N branches ascending, then keywords in CLDR order zero, one, two, few, many, other. Select: source order, other included.
Field Type Meaning
locale string Declared locale.
status "translated", "inherited" or "fallback" translated: this locale’s own catalog had a renderable value. inherited: resolved through a declared ancestor (de-AT through de); not missing. fallback: fell through to the source locale; missing.
from string or null Locale the value came from, for inherited and fallback.
reason "missing", "blank", "invalid" or null For fallback: no value, an empty value, or a value that failed to parse.
Field Type Meaning
file string POSIX, relative to the project root.
scope string or null Deepest enclosing function, method, class or variable declaration. null at module scope.
  • Messages sorted by key, locales sorted, args in first-appearance order, usage sites deduplicated and sorted by file then scope. Two-space indent, POSIX separators, LF, trailing newline.
  • No timestamps, absolute paths or tool version.
  • No line numbers: one added import would churn every entry below it on a pull request that touched no string, and two pull requests editing one component would conflict inside generated JSON. Line, column and snippet stay in the JSON reporter and in BuildResult.program.usages from loclizr/compiler.
  • Host ICU data and Node version are not inputs. Only diagnostics read Intl at build time (LZ3007, LZ3013, and LZ1019 for small ICU), so a laptop and a runner can disagree on those, never on this file. Pin Node (engines or .nvmrc) for the diagnostics.
Part, on the example Share
Whole file 23,774 bytes over 26 messages, about 914 per message
translations close to half
usage about an eighth
source, sourceHash, description, args, variants, markup about a quarter

The last row is the contract the gate compares and a reviewer reads; the two arrays are context.

Reference hashes: a826cf6a40d3293e for the cart.items source above, 3a78695388b38b5c for Home (nav.home). Different ones mean a different canonical form.

Rule Runs in Reports
LZ5003 record-stale check The committed record differs from the one this tree would produce.
LZ5007 record-rewritten build The record it just wrote differed from the committed one.

Both compare a projection: usage and translations are removed from every message on both sides, and the rest is compared after a stable re-serialization. A committed record that is missing or fails to parse counts as differing.

In the file In the comparison
schema, sourceLocale, locales yes yes
key, id, module, kind yes yes
source, sourceHash, description yes yes
args, variants, markup yes yes
translations yes no
usage yes no
  • usage moves with renames, file moves and tokenizer fixes; nothing the scan produces may fail a build by default.
  • translations moves with every translator commit or TMS sync, which is not a contract change.
  • Copy edits, added or removed messages and argument changes all stay in the comparison.
  • build still writes the whole file on any byte difference; only the rule compares the projection.
  • build replaces only a file it can tell is a record: an object with schema: 1, or one a merge left conflict markers in. Anything else at the path is LZ5001 output-unwritable and stays.

The record has no translation text or hashes, so the gettext fuzzy signal is a two-file diff: a copy edit changes sourceHash and not de.json; a translation changes de.json and not the record, unless coverage changed.

Run check in CI, not build, which rewrites the record and passes; see Continuous integration.

git diff, after rewording cart.greeting and running build
diff --git a/locales/en.json b/locales/en.json
index e1732a1..567cd72 100644
--- a/locales/en.json
+++ b/locales/en.json
@@ -21,7 +21,7 @@
"cart": "Cart"
},
"cart": {
- "greeting": "Hi {name}, your cart is ready",
+ "greeting": "Hello {name}, your cart is ready",
"items": "{count, plural, =0 {Your cart is empty} one {# item in your cart} other {# items in your cart}}",
"total": "Total: {amount, number, ::currency/USD}",
"updated": "Updated {at, date, medium}"
diff --git a/locales/loclizr.context.json b/locales/loclizr.context.json
index 561f905..328a8e3 100644
--- a/locales/loclizr.context.json
+++ b/locales/loclizr.context.json
@@ -582,8 +582,8 @@
"id": "cart_greeting",
"module": "messages/cart.js",
"kind": "text",
- "source": "Hi {name}, your cart is ready",
- "sourceHash": "54db56b1aeaf77ae",
+ "source": "Hello {name}, your cart is ready",
+ "sourceHash": "cb7288f2f820ef90",
"description": null,
"args": [
{

A rewording in a copy of the example: source and sourceHash move together, nothing else, and de.json is untouched.

git diff, after adding a count placeholder to cart.greeting
diff --git a/locales/loclizr.context.json b/locales/loclizr.context.json
index 328a8e3..f0bfa6f 100644
--- a/locales/loclizr.context.json
+++ b/locales/loclizr.context.json
@@ -582,8 +582,8 @@
"id": "cart_greeting",
"module": "messages/cart.js",
"kind": "text",
- "source": "Hello {name}, your cart is ready",
- "sourceHash": "cb7288f2f820ef90",
+ "source": "Hello {name}, your {count} items are ready",
+ "sourceHash": "220d19181cdee30a",
"description": null,
"args": [
{
@@ -591,6 +591,12 @@
"type": "text",
"options": null,
"note": null
+ },
+ {
+ "name": "count",
+ "type": "text",
+ "options": null,
+ "note": null
}
],
"variants": [],

Adding a placeholder grows args, and the same build fails LZ3004 arg-missing for de and de-AT, whose translations no longer carry it.

Check, in diff order:

  1. The record has a hunk under the same key as the catalog hunk.
  2. A new placeholder shows as an args entry with the type the call site will demand; a new plural shows under variants.
  3. The description still fits, or changed with the text.
  4. Every target stays translated after a rewording; deleting a target value flips one to fallback.
  5. The usage sites are the files a translator would open to see the string.

Of five things a translator wants at every string, the record has two:

Has Does not have
The render site: file and enclosing declaration Element role: a button label or a heading
Argument types, including a select’s options and whether a number selects a plural Surrounding copy on the same screen
A glossary

The description is where a human puts element role and surrounding copy today; LZ3012 ambiguous-source asks for one when two keys share a source text.

The usage field is a heuristic, not a resolution:

  • Imports bind by specifier suffix: ./loclizr/messages, @/loclizr/messages and #app/loclizr/groups all bind; no alias map is read.
  • A computed access on a bound group counts against every member; on a plain namespace object it is not resolved. A renamed re-export is not followed.
  • It errs toward false positives: an extra site is still a real file, a missed one loses the payload.