A Formatter defines how translation files are read and written. It specifies the file extension and provides functions to parse file contents into messages and stringify messages back to file format.
Properties
File extension for translation files, including the leading dot.extension: '.po' // PO files
extension: '.json' // JSON files
extension: '.yaml' // YAML files
The extension is used in the bucket output template: {locale}/messages.{extension}
Async function that parses file content into an array of messages.Signature:parse: (content: string, context: { locale: string }) => Promise<Message[]>
Parameters:
content - Raw file contents as a string
context.locale - The locale being parsed (e.g., "en", "fr")
Returns: Array of Message objects
Async function that converts messages into file content.Signature:stringify: (messages: Message[], context: { locale: string }) => Promise<string>
Parameters:
messages - Array of Message objects to serialize
context.locale - The locale being written (e.g., "en", "fr")
Returns: Formatted file content as a string
Message Type
Both parse and stringify work with the Message type:
type Message = {
message: string; // Source message text
translation?: string; // Translated text (optional)
id?: string; // Unique message identifier (optional)
context?: string; // Context to disambiguate messages (optional)
comments: string[]; // Translator comments
references: string[]; // Source code locations (e.g., "src/App.tsx:42")
};
Saykit uses @saykit/format-po as the default formatter. It reads and writes Gettext PO files.
From @saykit/format-po/src/formatter.ts:4:
import type { Formatter } from '@saykit/config';
import PO from 'pofile';
function createFormatter(): Formatter {
return {
extension: '.po',
async parse(content, context) {
const po = PO.parse(content);
if (!po.headers['X-Generator']?.startsWith('saykit'))
throw new Error('PO file was not generated by saykit');
if (po.headers.Language !== context.locale)
throw new Error('PO file locale does not match the expected locale');
return po.items.map((item) => {
const id = item.extractedComments
.find((c) => c.startsWith('id:'))
?.slice(3);
const comments = item.extractedComments
.filter((c) => !c.startsWith('id:'))
.map((c) => c.trim());
return {
id,
context: item.msgctxt,
message: item.msgid,
translation: item.msgstr[0],
comments,
references: item.references,
};
});
},
async stringify(messages, context) {
const po = new PO();
po.headers['Content-Type'] = 'text/plain; charset=UTF-8';
po.headers['Content-Transfer-Encoding'] = '8bit';
po.headers.Language = context.locale;
po.headers['X-Generator'] = 'saykit';
for (const message of messages) {
const item = new PO.Item();
item.msgid = message.message;
if (message.context) item.msgctxt = message.context;
item.msgstr = [message.translation ?? ''];
const comments = [];
if (message.id) comments.push(`id:${message.id}`);
if (message.comments.length) comments.push(...message.comments);
item.extractedComments = comments;
item.references = message.references;
po.items.push(item);
}
return po.toString();
},
};
}
export default createFormatter;
import type { Formatter, Message } from '@saykit/config';
export function jsonFormatter(): Formatter {
return {
extension: '.json',
async parse(content, context) {
const data = JSON.parse(content);
if (data.locale !== context.locale) {
throw new Error(`Locale mismatch: expected ${context.locale}, got ${data.locale}`);
}
return data.messages || [];
},
async stringify(messages, context) {
const data = {
locale: context.locale,
generatedAt: new Date().toISOString(),
messages,
};
return JSON.stringify(data, null, 2);
},
};
}
Simple key-value format:
import type { Formatter, Message } from '@saykit/config';
export function flatJsonFormatter(): Formatter {
return {
extension: '.json',
async parse(content, context) {
const obj = JSON.parse(content);
return Object.entries(obj).map(([id, translation]) => ({
id,
message: id,
translation: translation as string,
comments: [],
references: [],
}));
},
async stringify(messages, context) {
const obj: Record<string, string> = {};
for (const msg of messages) {
const key = msg.id || msg.message;
obj[key] = msg.translation || msg.message;
}
return JSON.stringify(obj, null, 2);
},
};
}
import type { Formatter, Message } from '@saykit/config';
import YAML from 'yaml';
export function yamlFormatter(): Formatter {
return {
extension: '.yaml',
async parse(content, context) {
const data = YAML.parse(content);
if (!Array.isArray(data.messages)) {
throw new Error('Invalid YAML format: expected messages array');
}
return data.messages;
},
async stringify(messages, context) {
const data = {
locale: context.locale,
messages,
};
return YAML.stringify(data);
},
};
}
Define the formatter in your bucket configuration:
import { defineConfig } from '@saykit/config';
import { jsonFormatter } from './formatter-json';
export default defineConfig({
sourceLocale: 'en',
locales: ['en', 'fr', 'es'],
buckets: [
{
include: ['src/**/*.ts'],
output: 'src/locales/{locale}/messages.{extension}',
formatter: jsonFormatter(),
},
],
});
Type Definition
From @saykit/config/src/shapes.ts:18:
export const Formatter = z.object({
extension: z.templateLiteral(['.', z.string()]).transform((v) => v.slice(1)),
parse: z.custom<
(content: string, context: { locale: string }) => Promise<Message[]>
>((v) => typeof v === 'function'),
stringify: z.custom<
(messages: Message[], context: { locale: string }) => Promise<string>
>((v) => typeof v === 'function'),
});
export const Message = z.object({
message: z.string(),
translation: z.string().optional(),
id: z.string().optional(),
context: z.string().optional(),
comments: z.string().array(),
references: z.string().array(),
});
Best Practices
Validation: Always validate the locale in parse() to ensure files match the expected language.
Metadata: Include generation timestamps and tool information in stringify() to help identify file origins.
Error Handling: Throw descriptive errors when parsing fails so developers can quickly identify issues.
Encoding: Use UTF-8 encoding for all translation files to support international characters.