Skip to main content
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

extension
string
required
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}
parse
function
required
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
stringify
function
required
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")
};

Default Formatter

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;

Custom Formatter Examples

JSON Formatter

formatter-json.ts
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);
    },
  };
}

Flat JSON Formatter

Simple key-value format:
formatter-flat-json.ts
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);
    },
  };
}

YAML Formatter

formatter-yaml.ts
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);
    },
  };
}

Using a Custom Formatter

Define the formatter in your bucket configuration:
saykit.config.ts
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.

Build docs developers (and LLMs) love