Formats
PO, JSON, and other translation file formats, and how to write your own
A formatter in SayKit is a pluggable adapter that knows how to parse and stringify one translation file format. The bucket calls it during extraction (to write) and during build (to read), but it's completely up to the formatter what file format ends up on disk.
import po from '@saykit/format-po';
buckets: [
{
// ...
formatter: po(),
},
];The default first-party formatter is PO (Gettext Portable Object). It's the format every SayKit example uses, and the one most translation tools and translators already understand. A JSON formatter (@saykit/format-json) is also shipped for projects that prefer plain JSON bundles with no Gettext tooling.
PO
PO files look like this:
msgid ""
msgstr ""
"Project-Id-Version: \n"
"Language: fr\n"
"Content-Type: text/plain; charset=UTF-8\n"
"X-Generator: saykit\n"
#. Translator comment from the source
#: src/app/page.tsx:14
#: src/app/inbox.tsx:8
msgid "Hello, {name}!"
msgstr "Bonjour, {name} !"
msgid "Inbox"
msgctxt "noun"
msgstr "Boîte de réception"Each entry has:
msgid, the source string (or ICU MessageFormat for plural/select)msgstr, the translation (empty for new entries)msgctxt, the descriptor's context, if any#., translator comments (from// TRANSLATORS:lines)#:, source references (file:line)#. id:xxxx, SayKit's stable id (for messages without a custom id)
Why PO?
Universal
Decades of tooling. POEdit, Crowdin, Lokalise, Weblate, Transifex all speak PO natively.
Human-readable
A reviewer can read a PO diff. Translators can edit them in any text editor if needed.
Diff-friendly
Line-based and entry-aligned. PRs reviewing translations are sensible to read.
Tooling
Lots of CLI utilities exist (msgmerge, msgfmt, msgcat) if you ever need to munge PO files outside SayKit.
Options
Prop
Type
formatter: po({ includeReferences: true, includeLineNumbers: false });Dropping line numbers is a popular choice once a project is large, references still tell translators which files use a string, but .po diffs no longer churn every time a line moves.
JSON
@saykit/format-json writes one JSON catalogue per locale — the de facto format for web i18n (react-intl, FormatJS, i18next). It's a lean runtime format: each entry is keyed by its message id (falling back to a stable content hash when there's no id, the same key the runtime resolves), with the translation as the value.
import json from '@saykit/format-json';
buckets: [
{
// ...
formatter: json(),
},
];{
"greeting": "Bonjour, {name} !",
"inbox": "Boîte de réception"
}This plain layout is lean but drops comments, context, and source references — give your messages explicit ids to get stable, readable keys.
Dialects
To keep the metadata a plain { key: value } map can't hold, pass dialect to switch to a richer — but still standard — JSON layout.
Prop
Type
formatter: json({ dialect: 'arb' });{
"@@locale": "fr",
"greeting": "Bonjour, {name} !",
"@greeting": {
"description": "A friendly hello",
"x-saykit-context": "formal",
"x-saykit-references": ["src/app/page.tsx:14"]
}
}Both formats carry translator comments in their native description field. Context and source references have no standard slot, so SayKit round-trips them through x-saykit-context / x-saykit-references extension fields — other tooling reads the description and safely ignores the rest.
References
As with PO, you can trim or drop the source references a dialect writes. Neither option affects the plain layout, which carries no metadata to begin with.
Prop
Type
formatter: json({ dialect: 'arb', includeLineNumbers: false });Other formats
PO and JSON are the formatters shipped today. If you need YAML, XLIFF, or a custom format, you can write one yourself.
A Formatter is just an object:
import type { Formatter } from '@saykit/config';
const json: Formatter = {
extension: '.json',
parse(content) {
/* return Message[] */
},
stringify(messages, { locale, existingContent }) {
/* return string */
},
};See the custom formatter guide for a complete walkthrough.
The Message shape
Formatters work with this shape:
Prop
Type
Your formatter receives an array of these on stringify, and must return an array of them on parse. Anything you can map back-and-forth, you can support.
Next
- Custom formatter guide, write your own
- Configuration, where formatters plug in