Skip to main content
Saykit uses formatters to read and write translation files. While the default PO format works for most projects, you can create custom formatters to support any file format you need.

Formatter Interface

A formatter is an object that defines how to parse and stringify translation messages:
interface Formatter {
  extension: string;
  parse: (content: string, context: { locale: string }) => Promise<Message[]>;
  stringify: (messages: Message[], context: { locale: string }) => Promise<string>;
}

interface Message {
  message: string;           // Source message text
  translation?: string;      // Translated text
  id?: string;              // Optional custom ID
  context?: string;         // Disambiguation context
  comments: string[];       // Translator comments
  references: string[];     // Source file locations
}

Properties

The file extension for this format (without the leading dot).
extension: 'po'    // generates .po files
extension: 'json'  // generates .json files
extension: 'yaml'  // generates .yaml files
Converts file content into an array of Message objects.Parameters:
  • content: The file content as a string
  • context.locale: The target locale being parsed
Returns: A promise resolving to an array of messagesThis method is called when:
  • Loading existing translations
  • Re-running extraction to preserve translations
Converts an array of Message objects into file content.Parameters:
  • messages: Array of messages to serialize
  • context.locale: The target locale being written
Returns: A promise resolving to the file content stringThis method is called when:
  • Generating new translation files
  • Updating existing files after extraction

Example: PO Formatter

The built-in PO formatter demonstrates a complete implementation:
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);

      // Validate the file
      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');

      // Convert PO items to Message objects
      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();

      // Set headers
      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';

      // Convert Message objects to PO items
      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;
The PO formatter is available as @saykit/format-po and is used by default when no formatter is specified.

Example: JSON Formatter

Here’s a simple JSON formatter for flat key-value translation files:
import type { Formatter, Message } from '@saykit/config';

function createJSONFormatter(): Formatter {
  return {
    extension: 'json',

    async parse(content, context) {
      const data = JSON.parse(content);
      const messages: Message[] = [];

      for (const [key, value] of Object.entries(data)) {
        if (typeof value === 'string') {
          messages.push({
            id: key,
            message: value,
            translation: value,
            comments: [],
            references: [],
          });
        }
      }

      return messages;
    },

    async stringify(messages, context) {
      const data: Record<string, string> = {};

      for (const message of messages) {
        const key = message.id || message.message;
        data[key] = message.translation || message.message;
      }

      return JSON.stringify(data, null, 2);
    },
  };
}

export default createJSONFormatter;

Output Example

en.json
{
  "welcome.greeting": "Hello, {name}!",
  "cart.item_count": "{count, plural, one {# item} other {# items}}",
  "loading": "Loading..."
}

Example: YAML Formatter

A YAML formatter with nested structure and metadata:
import type { Formatter, Message } from '@saykit/config';
import YAML from 'yaml';

interface YAMLMessage {
  message: string;
  translation?: string;
  context?: string;
  comments?: string[];
}

function createYAMLFormatter(): Formatter {
  return {
    extension: 'yaml',

    async parse(content, context) {
      const data = YAML.parse(content) as Record<string, YAMLMessage>;
      const messages: Message[] = [];

      for (const [id, item] of Object.entries(data)) {
        messages.push({
          id,
          message: item.message,
          translation: item.translation,
          context: item.context,
          comments: item.comments || [],
          references: [],
        });
      }

      return messages;
    },

    async stringify(messages, context) {
      const data: Record<string, YAMLMessage> = {};

      for (const message of messages) {
        const id = message.id || message.message;
        data[id] = {
          message: message.message,
          ...(message.translation && { translation: message.translation }),
          ...(message.context && { context: message.context }),
          ...(message.comments.length && { comments: message.comments }),
        };
      }

      return YAML.stringify(data);
    },
  };
}

export default createYAMLFormatter;

Output Example

en.yaml
welcome.greeting:
  message: "Hello, {name}!"
  translation: "Hello, {name}!"
  comments:
    - "Shown on the home page"

cart.empty:
  message: "Your cart is empty"
  translation: "Your cart is empty"
  context: "shopping"

loading:
  message: "Loading..."
  translation: "Loading..."

Using Custom Formatters

Apply your formatter in the bucket configuration:
saykit.config.ts
import { defineConfig } from '@saykit/config';
import createJSONFormatter from './formatters/json';

export default defineConfig({
  sourceLocale: 'en',
  locales: ['en', 'fr', 'es'],
  buckets: [
    {
      include: ['src/**/*.tsx'],
      output: 'src/locales/{locale}/messages.{extension}',
      formatter: createJSONFormatter(),
    },
  ],
});

Multiple Formatters

Use different formatters for different buckets:
saykit.config.ts
import { defineConfig } from '@saykit/config';
import createJSONFormatter from './formatters/json';
import createYAMLFormatter from './formatters/yaml';

export default defineConfig({
  sourceLocale: 'en',
  locales: ['en', 'fr'],
  buckets: [
    {
      include: ['src/components/**/*.tsx'],
      output: 'locales/{locale}/components.{extension}',
      formatter: createJSONFormatter(),
    },
    {
      include: ['src/pages/**/*.tsx'],
      output: 'locales/{locale}/pages.{extension}',
      formatter: createYAMLFormatter(),
    },
  ],
});

Publishing Formatters

Share your formatter as an npm package:
1

Create Package Structure

@yourorg/saykit-format-json/
├── src/
│   └── index.ts
├── package.json
└── tsconfig.json
2

Export Factory Function

src/index.ts
import type { Formatter } from '@saykit/config';

export default function createJSONFormatter(): Formatter {
  return {
    extension: 'json',
    async parse(content, context) { /* ... */ },
    async stringify(messages, context) { /* ... */ },
  };
}
3

Configure Package

package.json
{
  "name": "@yourorg/saykit-format-json",
  "version": "1.0.0",
  "type": "module",
  "main": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "peerDependencies": {
    "@saykit/config": "^1.0.0"
  }
}
4

Use in Projects

npm install @yourorg/saykit-format-json
saykit.config.ts
import { defineConfig } from '@saykit/config';
import createJSONFormatter from '@yourorg/saykit-format-json';

export default defineConfig({
  sourceLocale: 'en',
  locales: ['en', 'fr'],
  buckets: [
    {
      include: ['src/**/*.tsx'],
      output: 'locales/{locale}.{extension}',
      formatter: createJSONFormatter(),
    },
  ],
});

Error Handling

Implement validation and clear error messages:
async parse(content, context) {
  let data;
  try {
    data = JSON.parse(content);
  } catch (error) {
    throw new Error(`Invalid JSON in ${context.locale} translation file`);
  }

  if (!data || typeof data !== 'object') {
    throw new Error(
      `Expected object in ${context.locale} translation file, got ${typeof data}`
    );
  }

  // Validate structure
  const messages: Message[] = [];
  for (const [key, value] of Object.entries(data)) {
    if (typeof value !== 'string') {
      console.warn(
        `Skipping non-string value for key "${key}" in ${context.locale}`
      );
      continue;
    }
    messages.push({ /* ... */ });
  }

  return messages;
}
Always validate input in the parse method. Translation files may be manually edited by translators and could contain unexpected formats or errors.

Best Practices

  1. Preserve Metadata: Store comments, context, and references when possible
  2. Handle Empty Values: Support messages without translations (for new extractions)
  3. Validate Locale: Check that the file locale matches the expected locale
  4. Sort Output: Keep consistent ordering for better version control diffs
  5. Format Nicely: Use proper indentation and line breaks for human readability
  6. Version Files: Include format version or generator info in output
  7. Document Format: Provide examples and explain any custom conventions

Next Steps

Build docs developers (and LLMs) love