SayKit
Core Concepts

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.

saykit.config.ts
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:

src/locales/fr.po
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.

saykit.config.ts
import json from '@saykit/format-json';

buckets: [
  {
    // ...
    formatter: json(),
  },
];
src/locales/fr.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' });
src/locales/fr.json (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

On this page