CSV is what your translators actually hand back: it opens in Google Sheets, in Excel, in LibreOffice, and it gives non-engineers a sane editing surface for thousands of keys. JSON (in the i18next-style nested shape) is what your web app consumes. Going from the translator's spreadsheet to the runtime bundle is therefore a daily operation in any team that doesn't run a TMS — and it's where most ad-hoc Python scripts get the structural detail wrong. CSV is flat; i18next JSON is nested. A key like app.title in the spreadsheet has to expand into {"app": {"title": "..."}} in JSON, or the namespacing logic in your client breaks. Quoting rules also differ: CSV escapes embedded commas and quotes with doubled-up "", while JSON uses backslash escapes.
i18n-convert reads the CSV with the first column as the key, the second column as the English value (the source-language convention used by all the major translator handoff templates), splits dotted keys on . to produce nested JSON objects, and sorts every level of the resulting tree alphabetically for deterministic output. Deterministic ordering matters more than it sounds: it makes diffs reviewable when a translator hands back a new column or an updated value, and it eliminates the noisy "the JSON output reshuffled itself" PRs that hand-rolled scripts produce. The fixture below is no_comments.csv from the test suite — three entries including a dotted key app.title that expands into a nested app object in the output.
Command
i18n-convert no_comments.csv --to i18next -o messages.json
Input
key,en
greeting,Hello
farewell,Goodbye
app.title,My App
Output
{
"app": {
"title": "My App"
},
"farewell": "Goodbye",
"greeting": "Hello"
}