Formats
The twelve supported formats, how each one reads and writes its files, and the document key order guarantee.
verbatra works on your locale files through a format adapter. Each adapter reads a file into one format-neutral shape that the diff, hashing, and integrity checks run on, then writes it back in the file's original shape. You pick the format with the format field in your config. Twelve are supported: four JSON flavors, plus XLIFF, YAML, ARB, Java/Spring properties, Apple .strings, Xcode String Catalogs (.xcstrings), Android strings.xml, and gettext .po/.pot.
The four JSON formats
format | For | Placeholders | Plurals | ICU |
|---|---|---|---|---|
i18next-json | i18next | {{name}} and $t(...) nesting references | CLDR plural suffix on the key | no |
vue-i18n-json | vue-i18n | {name}, {0} | pipe in the value | no |
next-intl-json | next-intl | ICU argument and tag names | ICU plural or selectordinal | yes |
ngx-translate-json | ngx-translate | {{name}} | none | no |
All four read nested JSON objects of string leaves. Their differences are the message syntax:
- i18next uses
{{double-brace}}interpolation and decides plural from the CLDR suffix on the key (_zero,_one,_two,_few,_many,_other). It additionally extracts and guards$t()nesting references (which splice another key's content into the value), for example$t(common.foo)and$t(common.foo, { options }), as placeholders, so a translation that drops or alters one fails the integrity check. Two limitations: nested parentheses inside the$t()options are not supported, and only the default$t(prefix is recognized. - Single-brace text under the double-brace formats. In
i18next-json,ngx-translate-json, andyaml, a{name}-shaped token is literal text, not interpolation, so it is not extracted as a placeholder and a translation is free to drop or reword it. See the fabrication guard for the one direction in which it is still checked. - vue-i18n uses single-brace
{name}and{0}tokens and decides plural from a pipe in the value. - next-intl values are ICU MessageFormat: the placeholders are the ICU argument and rich-text tag names, plural follows an ICU plural or selectordinal argument, and the ICU body is kept verbatim through the pipeline. A value that fails to parse as ICU is reported as invalid and skipped, never thrown.
- ngx-translate shares i18next's
{{double-brace}}interpolation but has no$t()nesting and no built-in plural or ICU. Its files may be flat (dotted keys) or nested, and verbatra preserves whichever style the file uses on write; a new target file is written nested. Uniquely, a file that mixes flat dotted keys with nested objects fails the read withMIXED_STRUCTURE, since such a file is ambiguous rather than guessable.
The single-brace fabrication guard
Available from 0.9.0
Under i18next-json, ngx-translate-json, and yaml, a {name}-shaped token that shows up in a translation and never appeared in the source is rejected as a fabrication. That covers a placeholder invented outright and one altered into a name the source never had. The guard runs in that direction only: verbatra has no setting for i18next's custom interpolation.prefix and interpolation.suffix, so if you have switched to single-brace delimiters, a translation that silently drops one of your placeholders is not caught.
Namespace layouts
Splitting strings across several namespace files per locale is common in i18next projects, with common.json, auth.json, and so on sitting next to each other under a locale directory. One verbatra config addresses exactly one file per locale: files.pattern is a literal path with {locale} substituted, it is never expanded as a glob, and there is no namespace token. A pattern such as public/locales/{locale}/*.json is looked up as a file literally named *.json and fails with SOURCE_UNREADABLE.
A single-namespace layout is the one that works as configured:
files: {
pattern: "public/locales/{locale}/common.json",
},One config per namespace
For a multi-namespace project, write one config per namespace and run verbatra once per config. Any filename works, since --config loads an explicit path by its extension:
verbatra translate --config verbatra.common.config.ts
verbatra translate --config verbatra.auth.config.tsEach run handles its own namespace: keys that are missing from a target file are translated as usual.
Only the namespace that ran last keeps change detection
Every config in the same working directory shares one verbatra.lock.json, and each translate run replaces that locale's recorded baselines with the keys it just processed. After the second run, the first namespace has no baselines left, so editing one of its source strings is reported as in sync by check and diff and is not retranslated. Changing the order of the runs does not help; whichever runs last wipes the other. To retranslate an edited string under this setup, delete that key from the target file (or delete the whole target file) so it counts as missing rather than changed.
XLIFF
The xliff format covers .xlf and .xliff files, XLIFF 1.2 (file/body/trans-unit) and 2.0 (file/unit/segment). Unlike the tree formats, an XLIFF document is a flat list of trans-units.
- Keys. Each entry is keyed by its trans-unit
id, falling back toresname. A unit with neither falls back to a positional key, which shifts when earlier units are added or removed, so give every unit a stableid. Two units resolving to the same key (typically a duplicateid) fail the read withINVALID_STRUCTURErather than silently keeping one. - Values. The value comes from
<target>when present, otherwise<source>. - Writes update targets in place. verbatra writes each value into its
<target>and leaves<source>, attributes, and<note>elements untouched, so the document round-trips. Because a flat key/value map cannot reconstruct an XLIFF document, the destination file must already exist: verbatra updates targets in a pre-seeded target file (the standard XLIFF workflow) and does not create a missing one; a missing destination fails withINVALID_STRUCTURE. - Inline markup. Inline placeholder elements (
x,g,bx,ex,ph,it,mrk) and single-brace{name}interpolation are extracted as placeholders and guarded across translation. On write, only those allow-listed elements (with their own minimal, non-executable attributes) survive as live markup in<target>; anything else in a translated value, including an unexpected element or a namespace mismatch, is written as plain text instead. - Notes as context. A trans-unit's
<note>(in 2.0, the unit's<notes><note>, shared by every segment in the unit) is read as developer context: it reaches the provider as disambiguation context and appears in theContextcolumn of an exported workbook. It is read-only and never written back.
YAML
The yaml format covers .yml and .yaml files: a nested tree in YAML syntax, the same shape as a nested JSON file, handled by the same tree pipeline. It assumes i18next-compatible {{double-brace}} interpolation, including the single-brace fabrication guard described above, and detects by extension only.
- YAML comments are not carried across a write, the same way JSON has no comment concept.
- Scalar non-string keys keep their string form (
1:reads as"1",true:as"true"). - A composite key (a map or sequence used as a mapping key) has no faithful string form, so the read fails with
INVALID_STRUCTUREinstead of silently collapsing it to text. - Malformed syntax is reported as
INVALID_YAML, and anchor-alias expansion is bounded, so a hostile document cannot blow up the parse.
ARB
The arb format covers Flutter's .arb files: JSON with a flat object of message keys alongside @-prefixed metadata (@key per-message metadata and @@-prefixed globals such as @@locale).
- Metadata is preserved, never translated.
@-prefixed keys are stripped before translation and merged back on write in their document position. A destination file that exists but is corrupt fails the write rather than silently erasing its metadata. - Messages are ICU. Placeholders, plural handling, and ICU validation work exactly as they do for
next-intl-json. - Descriptions become context. Each
@key.descriptionis read as developer context for that message: it reaches the provider as disambiguation context (never as text to translate) and appears in theContextcolumn of an exported workbook. It is read-only and never written back.
Properties
The properties format covers Java and Spring .properties files, detected by the .properties extension: a flat list of key/value lines. Keys are kept verbatim as flat keys, never split into a tree.
- Separators and comments. A key is separated from its value by
=,:, or whitespace, with any spaces around the separator ignored, matchingjava.util.Properties. A line whose first non-blank character is#or!is a comment. - Continuations and escapes. A line ending in a backslash continues onto the next. The standard escapes (
\t,\n,\r,\f,\\, an escaped separator or comment character) and\uXXXXare decoded on read. Input is read as UTF-8; on write, every non-ASCII character is emitted as an ASCII-safe\uXXXXescape, so the file still loads under a legacy ISO-8859-1 reader. - Order, comments, and blank lines are preserved. A write re-reads the destination and keeps its key order, comments, and blank lines: each existing key line is rewritten in place with its new value, and a key the file does not yet have is appended in source order. A duplicate key keeps its first position and takes the last value, matching
Properties.load. - Line endings follow the destination. A file containing any CRLF is written back entirely with CRLF, a CR-only file with CR, and everything else, including a file that does not exist yet, with LF. A mixed file therefore converges on one style rather than being kept line by line, and a
\ror\ninside a value is still escaped rather than emitted as a line break. This keeps a two-key translation change a two-line diff in the CRLF repositories these files often live in. - Placeholders are MessageFormat. Values are read as
java.text.MessageFormat, so{0}, the typed form{0,number}, the styled form{0,number,integer}, and named arguments such as{count}are extracted and guarded across translation. Sub-message arguments (plural,select,selectordinal,choice) are recognized, so translating the branch text stays a match while dropping or renaming an argument does not. - Limitation: single quotes are not interpreted. MessageFormat single-quote quoting is not honored, so a quoted literal such as
'{0}'is still read as a placeholder. This is deliberate, so an ordinary apostrophe in translated text never swallows a following placeholder.
Apple .strings
Available from 0.10.0
The apple-strings format covers Apple's .strings files for iOS and macOS: a flat sequence of "key" = "value"; statements, detected by the .strings extension. Plural rules live in the sibling .stringsdict file (same directory, same base name, .stringsdict extension), which verbatra reads and writes automatically alongside the .strings file, under the same apple-strings format id.
- Encoding is UTF-8 only. Xcode's
genstringscan emit UTF-16 with a byte-order mark. verbatra detects a UTF-16 byte-order mark, little or big endian, and rejects it withINVALID_STRUCTURErather than parsing it into corrupt, NUL-interleaved keys. Re-save the file as UTF-8 (Xcode does this natively) before running verbatra on it. - Comments and escapes. A
/* ... */block comment immediately before an entry is read as that entry's description: it reaches the provider as disambiguation context and appears in theContextcolumn of an exported workbook. It is read-only and never written back. A//line comment is preserved on write but carries no description. The escapes\",\\,\n,\t, and the four-hex-digit\Uunicode escape are decoded on read; on write, a quote, backslash, newline, and tab are escaped and every other character, including non-ASCII text, is written as raw UTF-8. - Missing destination. Unlike XLIFF, a
.stringsdestination that does not exist yet is synthesized from the entries rather than failing the write, matching the properties format's behavior. - Order, comments, and blank lines are preserved. A write re-reads the destination and rebuilds it from that structure: each existing key is rewritten in place with its new value, a key the destination lacks is appended in source order, and a key removed from the entries is dropped from the destination together with its own leading comment.
- Placeholders are printf-style.
%@,%d,%1$@, and the escaped literal%%are extracted and guarded across translation, with flags, width, precision, and length modifiers (%05.2f,%ld) treated as decoration and not part of the token's identity. A positional reorder such as%1$@ %2$@becoming%2$@ %1$@is accepted, since that is the purpose of positional specifiers. A bare%followed by ordinary text, as in"50% off", extracts nothing. - Plurals live in the sibling
.stringsdict.Localizable.stringspairs withLocalizable.stringsdictin the same directory; verbatra discovers it automatically and merges its plural categories into the same locale resource, so translating still targets onefiles.patternper locale. Each CLDR category present in an entry (zero,one,two,few,many,other) becomes its own translatable entry, keyed with the same<key>_<category>suffix conventioni18next-jsonuses, for examplephoto_count_oneandphoto_count_other. A locale that supplies only some categories, the common case since most languages need justoneandother, round-trips with exactly those: none is invented for a target locale and none is dropped from the source. Printf placeholders inside a category's format string, such as%din"%d photos", are extracted and guarded the same way as in.stringsvalues. A malformed.stringsdict(invalid XML, a missing%#@variable@substitution, or an unsupported plural category) raises a structured error naming the file and the key. A.stringsdictdestination that does not exist yet is created the same way a.stringsdestination is,.lprojdirectory included, and its non-translatable structure (the format key, the substitution variable name, and the value type) is preserved across a rewrite. - One file per locale,
.lprojincluded.files.patternwith{locale}.lproj/Localizable.stringsaddresses Apple's per-locale bundle layout directly:{locale}is a literal token substitution, so it needs no special locale style, and a write creates a missing{locale}.lprojdirectory the same way it creates any other missing destination directory.
Xcode String Catalogs (.xcstrings)
Available from 0.10.0
The apple-xcstrings format covers Xcode's String Catalog files, .xcstrings, introduced in Xcode 15 to replace the .strings/.stringsdict pair for new projects: a single JSON document holding every locale's strings, plurals, and translation state together, detected by the .xcstrings extension. This is structurally different from every other format verbatra supports, which is one file per locale: an apple-xcstrings catalogue is one file for every locale at once.
- One shared catalogue, not one file per locale.
files.patternstill requires the{locale}token, but for this format it resolves to the same path regardless of which locale is substituted, for example{locale}Localizable.xcstringsaddresses oneLocalizable.xcstringsfile. The source locale and every configured target locale therefore share one physical file. - Writes into a shared catalogue serialize. Because every locale's write touches the same file, verbatra serializes every operation that can write it, including
translate, a Studio edit, single-key retranslation, and workbook import, against one another. In practice this means anapple-xcstringsproject runs those operations one at a time even when--concurrencyis set above 1; every other format still runs its provider calls concurrently as configured. - The key is the source string. A catalogue key with no explicit
localizationsentry for the document's own declaredsourceLanguagefalls back to the key text itself, matching Xcode's own convention. A key missing an entry for any other locale is simply treated as not yet translated for that locale, the same way a missing key in any other format's target file is. - Plurals live in
variations.plural. Each CLDR category (zero,one,two,few,many,other) present under a locale'svariations.pluralbecomes its own translatable entry, keyed with the same<key>_<category>suffix conventioni18next-jsonand the Apple.stringsformat's.stringsdictsupport both use, for example%lld photos_oneand%lld photos_other. A locale that supplies only some categories round-trips with exactly those: none is fabricated and none is dropped. - Placeholders are printf-style.
%@,%d,%1$@,%lld, and the escaped literal%%are extracted and guarded across translation the same way Apple.stringsvalues are; a length modifier such as thellin%lldis decoration, not part of the token's identity, so%lldand%dare the same placeholder. shouldTranslate: falseis respected. A key the catalogue marks this way is never sent to a provider and is never written back changed.- Writes patch the document, they do not rebuild it. A write re-reads the current catalogue and updates only the localizations it touches, so
extractionState, every other locale, non-translatable entries, plural categories, the catalogue's top-levelversion, and itssourceLanguageall survive untouched. A value verbatra just translated getsstringUnit.state: "translated"; an unchanged value's existing localization, state included, is left byte-identical rather than rewritten. - The destination catalogue must already exist. Unlike
.stringsor.properties, verbatra does not create a new.xcstringscatalogue: create it in Xcode first, then pointfiles.patternat it. - Malformed input is specific. A structured
AdapterErrornames the file and, where the problem is inside one entry, the key and locale too, for example an entry missing both astringUnitand avariations.pluralfield, or a plural category outside the CLDR set.
Android strings.xml
Available from 0.10.0
The android-xml format covers Android's resource files, res/values/strings.xml for the source locale and res/values-<qualifier>/strings.xml for every target, detected by the .xml extension. Point files.localeStyle at android (see the config file) so {locale} in files.pattern expands to the right resource-qualifier directory instead of a literal BCP-47 tag.
<string>and<plurals>. A<string name="key">value</string>is one entry. A<plurals name="key">becomes one entry per<item quantity="...">(zero,one,two,few,many,other), keyed askey[quantity], for examplecount[one]andcount[other]. A locale that supplies only some quantities round-trips with exactly those: none is fabricated and none is dropped. Every resourcenameis validated against Android's identifier grammar (a letter or underscore, then letters, digits, or underscores) before it becomes a key, so a name cannot forge a[quantity]-shaped collision with real plural key space.translatable="false"is respected. A<string>or<plurals>carrying this attribute is never sent to a provider and is left untouched on disk.formatted="false"is translated normally. The attribute only tells Android's own build tooling not to validate printf arguments; it does not change what verbatra does.%s/%d-style placeholders are still extracted and guarded the same way.<string-array>and inline markup are read-through in this version. A<string-array>and a<string>whose content is not plain text (an inline element such as<b>,<xliff:g>, or aCDATAsection) are preserved exactly as they are and never enter verbatra's translatable set. Item-level translation for these is a possible future addition, not supported today.- Escaping is decoded on read, re-encoded on write.
\',\",\n,\t, and a leading\@or\?are decoded to their literal characters when read, and the same characters (plus a leading, unescaped@or?) are re-escaped on write; XML's own&,<, and>entities are handled independently by the XML layer underneath, so a translated value containing any of these characters is written back safely without you having to escape it yourself. - Placeholders are printf-style.
%s,%d,%1$s, and the escaped literal%%are extracted and guarded across translation. A bare%followed by ordinary text, as in"50% off", extracts nothing: the extractor does not treat a space as a valid conversion flag, unlike Java's ownString.format, so a percentage sign in ordinary prose is never mistaken for a placeholder. - Writes update targets in place; a missing destination is synthesized. An existing
res/values-<qualifier>/strings.xmlis patched: a translated key is updated, a key removed from the source is deleted, and the parent directory is created if it does not exist yet. A destination that doesn't exist yet is created from scratch,res/values-<qualifier>/directory included. - Limitation: a key demoted to read-through after translation is pruned, not preserved. If a key was already translated in a target file and then, in the source file, gains
translatable="false"or inline markup, the next run cannot tell that apart from the key having been deleted outright, and removes the stale translation from the target file. This is narrow (it only affects a key whose source classification changes after it was already translated) and recoverable through version control or a fresh translate run; it does not affect a key that keeps its original classification, including ordinary new, changed, and removed keys. - Limitation:
--prunehas no plural-quantity awareness. verbatra's orphan and--prunedetection compares keys against the source's own key set, with no special case forkey[quantity]groups. A target locale needing more plural quantities than the source declares (for example a language with more grammatical number distinctions) has target-only quantities that a--prunerun cannot distinguish from a genuinely removed key, and can delete them. Off by default; an ordinaryverbatra translaterun without--pruneis unaffected. - Malformed input is specific. Malformed XML raises a structured
AdapterErrornaming the problem; a document whose root element is not<resources>, a resource name that fails the identifier grammar, a<plurals>item with a quantity outside the CLDR set, or two elements resolving to the same key are all reported by name rather than as a generic parse failure. A DTD or entity declaration is rejected outright.
gettext .po/.pot
Available from 0.10.0
The gettext-po format covers GNU gettext's .po and .pot catalogs, detected by either extension: a flat sequence of msgid/msgstr entries, keyed by their msgid, never split into a tree.
msgctxtdisambiguation. Amsgctxtbefore an entry composes with itsmsgidinto one key, so two entries sharing amsgidunder different contexts never collide. The composite uses a reserved private-use codepoint (U+E000) as its internal separator, a character no real.pofile legitimately contains; a source string that does contain it is rejected with a structured error rather than silently corrupting the key.- Plurals are
msgid_plural/msgstr[n], keyed by index, not CLDR category. Eachmsgstr[n]becomes its own entry, keyed askey[n](nthe raw decimal index), for exampleone item[0]andone item[1]. This is deliberately not the<key>_<category>suffix the JSON formats and Apple's.stringsdictuse: turning a gettext index into a CLDR category would require evaluating the file's ownPlural-FormsC expression, which verbatra does not do. ThePlural-Formsheader is preserved verbatim as untouched text; verbatra only reads thenplurals=Ninteger out of it, to bound-check that everymsgstr[n]index is in range. A file with plural entries and noPlural-Formsheader is rejected, matching whatmsgfmt --checkitself enforces. fuzzyentries are read, not skipped. A#, fuzzyentry's existingmsgstris read as that entry's current value, the same as any other translated entry; verbatra does not treatfuzzyas meaning missing. The flag is preserved verbatim on every write and is never added or cleared automatically.- Comments and the header round-trip untouched.
#.developer comments become the entry's description: they reach the provider as disambiguation context and appear in theContextcolumn of an exported workbook.#:source references and other#,flags are preserved on write but not otherwise interpreted. A#~obsolete block is preserved as inert text and never becomes a translatable entry. The header entry (themsgid ""block carryingContent-Type,Plural-Forms, and similar metadata) is never touched by a write. .pottemplates read cleanly. A.potsource template, whose entries carry an emptymsgstr, reads without error; each entry's value is simply the empty string until translated.- Missing destination. Like the properties and Apple
.stringsformats, a.podestination that does not exist yet is synthesized from the entries rather than failing the write, including a minimal header. A synthesized file with plural entries gets aPlural-Formsheader sized to the highest index present; for one or two forms this is the standard universal expression, for three or more it is a safe in-range fallback rather than a guessed, possibly wrong, linguistic rule; seed the destination from a real target-locale template first if you need the exact grammar. - Placeholders are printf-style, including Python's named form.
%s,%d,%1$s, the Python-style%(name)s, and the escaped literal%%are extracted and guarded across translation. - Malformed input is specific. A structured
AdapterErrornames the offendingmsgidand the physical line, for example an unterminated quoted string, an unknown escape sequence, a non-contiguousmsgstr[n]index, or a plural entry with noPlural-Formsheader, rather than a generic parse failure. - Limitation:
--prunehas no plural-index awareness. verbatra's orphan and--prunedetection compares keys against the source's own key set, with no special case forkey[n]groups. A target locale needing more plural forms than the source declares (for example a language with more grammatical number distinctions) has target-only indices that a--prunerun cannot distinguish from a genuinely removed key, and can delete them. Off by default; an ordinaryverbatra translaterun without--pruneis unaffected.
Document key order
Writes preserve your document's key order. The JSON family, YAML, and ARB round-trip keys exactly in document order:
- An integer-like key such as
"2","10", or"404"keeps its position instead of being hoisted to the front and re-sorted, so a file keyed by numeric ids, HTTP status codes, or years stays in its own order. - A key that a
translaterun adds to a target file appends after the target's existing keys, following the source document's order, rather than being inserted alphabetically. - ARB metadata blocks round-trip in their document position too.
XLIFF is unaffected: it updates existing <target> elements in place, so the document's order was never rebuilt in the first place.
Dotted keys and collisions
For i18next-json, vue-i18n-json, and next-intl-json, a literal dotted leaf (a key such as "foo.bar" used as a single leaf) round-trips losslessly: verbatra reads it and writes it back with its on-disk shape preserved, not re-nested into foo then bar. Real nested paths stay nested.
The one case that fails by design is a genuine collision, where one file expresses the same effective path both as a literal dotted leaf and as a real nested path (for example "foo.bar" alongside "foo": { "bar": ... }). That read fails with INVALID_STRUCTURE rather than guessing or corrupting data.
ngx-translate-json treats a dotted key as a nested path rather than a literal leaf, so there is no dotted-vs-literal ambiguity to preserve, but it gets the same collision safety: a dotted key whose path collides with a real nested path, including one being an ancestor of the other, fails with INVALID_STRUCTURE instead of silently overwriting a value.
How files are read and written
verbatra caps input size and nesting depth on read, and resists a file changing underneath it. It writes atomically: it writes to a temporary file, then renames it into place, so an interrupted write never leaves a half-finished locale file. When verbatra cannot handle a file, it raises a structured, secret-free error with a stable code rather than a raw one: INVALID_JSON, INVALID_YAML, or INVALID_XML for malformed syntax, INVALID_STRUCTURE for a parseable file of the wrong shape, MAX_DEPTH_EXCEEDED and INPUT_TOO_LARGE for the caps, and MIXED_STRUCTURE for ngx-translate's mixed-style case.
A tree-based locale file (the JSON family, YAML, and ARB) can carry a leaf that is not a string, for example a count: 5 or enabled: true value sitting next to the translatable keys. verbatra accepts these: a leaf can be a string, a number, a boolean, or null, and only a leaf of another type, such as an array, fails with INVALID_STRUCTURE. A non-string leaf is excluded from the translatable set: it is never translated, hashed, diffed, or checked for placeholders or ICU. Exclusion is not preservation: if verbatra later writes that same locale file, the write is rebuilt from the strings it manages, so a non-string leaf present at read time is not carried into the output. If such a value needs to survive a rewrite, keep it out of the files verbatra writes to. XLIFF trans-unit values and .properties values are always strings, so this does not apply there.
Why the format matters
The format tells verbatra two things it needs for a safe run. First, the placeholder syntax, so the placeholder integrity check knows what to compare before and after translation. Second, for the ICU formats, which values to validate, so an invalid ICU source key is skipped rather than sent in a broken state. The wrong format compares the wrong tokens, so match it to your i18n library: i18next-json for i18next, vue-i18n-json for vue-i18n, next-intl-json for next-intl, ngx-translate-json for ngx-translate, xliff for XLIFF files, yaml for YAML-based i18n, arb for Flutter, properties for Java or Spring .properties files, apple-strings for Apple .strings files, apple-xcstrings for Xcode String Catalog .xcstrings files, android-xml for Android strings.xml, and gettext-po for gettext .po/.pot catalogs.